120 lines
6.7 KiB
Python

#!/usr/bin/env python3
"""Standalone Art-Net channel mapper. Saves compact, grouped v2 JSON."""
import argparse, ipaddress, json, math, socket, struct, threading, time
from pathlib import Path
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
ROOT=Path(__file__).resolve().parent
DATA=ROOT/'mapping.json'
def flatten(doc):
if doc.get('version') == 1:
return list(doc.get('emitters',[]))
out=[]
for controller in doc.get('controllers',[]):
ip=controller['ip']
for universe in controller.get('universes',[]):
for pixel in universe.get('pixels',[]):
xyz=pixel['xyz']
for region,ch in pixel.get('channels',{}).items():
out.append(dict(x=xyz[0],y=xyz[1],z=xyz[2],region=region,controller=ip,universe=universe['id'],channel=ch,notes=pixel.get('notes','')))
return out
def compact(records):
controllers={}
for r in records:
ip=r['controller']; universe=int(r['universe']); xyz=(r['x'],r['y'],r['z']); region=r['region']; channel=int(r['channel'])
if not 1<=channel<=512 or not 0<=universe<=32767:raise ValueError('invalid DMX address')
universes=controllers.setdefault(ip,{})
pixels=universes.setdefault(universe,{})
# One pixel per physical coordinate per universe. Unknown region records get unique channel slots.
key=(xyz, channel) if region=='unknown' else xyz
pixel=pixels.setdefault(key,{'xyz':list(xyz),'channels':{}})
if region in pixel['channels']:raise ValueError('duplicate region at coordinate')
pixel['channels'][region]=channel
if r.get('notes'):pixel['notes']=r['notes']
return {'version':2,'controllers':[{'ip':ip,'universes':[{'id':u,'pixels':list(pixels.values())} for u,pixels in sorted(universes.items())]} for ip,universes in sorted(controllers.items())]}
class State:
def __init__(self):
self.ip='192.168.1.101';self.universe=0;self.channel=1;self.brightness=30;self.enabled=False
self.seq=1;self.last=time.monotonic();self.lock=threading.RLock();self.sock=socket.socket(socket.AF_INET,socket.SOCK_DGRAM)
try:self.records=flatten(json.loads(DATA.read_text()))
except FileNotFoundError:self.records=[]
def send(self,ip,universe,ch=None,level=0):
frame=bytearray(512)
if ch is not None:frame[ch-1]=level
packet=b'Art-Net\0'+struct.pack('<H',0x5000)+struct.pack('>H',14)+bytes([self.seq,0])+struct.pack('<H',universe)+struct.pack('>H',512)+frame
self.seq=self.seq%255+1;self.sock.sendto(packet,(ip,6454))
def off(self):self.enabled=False;self.send(self.ip,self.universe)
def save(self):
tmp=DATA.with_suffix('.tmp');tmp.write_text(json.dumps(compact(self.records),indent=2)+'\n');tmp.replace(DATA)
def snapshot(self):return dict(ip=self.ip,universe=self.universe,channel=self.channel,brightness=self.brightness,enabled=self.enabled,records=len(self.records))
s=State()
def main():
p=argparse.ArgumentParser();p.add_argument('--bind',default='127.0.0.1');p.add_argument('--port',type=int,default=8080);a=p.parse_args()
def sender():
while True:
with s.lock:
if time.monotonic()-s.last>30:s.enabled=False
s.send(s.ip,s.universe,s.channel,s.brightness) if s.enabled else s.send(s.ip,s.universe)
time.sleep(.1)
threading.Thread(target=sender,daemon=True).start()
class Handler(BaseHTTPRequestHandler):
def respond(self,obj,status=200):
b=json.dumps(obj).encode();self.send_response(status);self.send_header('Content-Type','application/json');self.send_header('Content-Length',str(len(b)));self.end_headers();self.wfile.write(b)
def do_GET(self):
if self.path=='/':
b=(ROOT/'index.html').read_bytes();self.send_response(200);self.send_header('Content-Type','text/html; charset=utf-8');self.send_header('Content-Length',str(len(b)));self.end_headers();self.wfile.write(b)
elif self.path=='/api/state':
with s.lock:self.respond(s.snapshot())
elif self.path=='/api/export':
with s.lock:self.respond(compact(s.records))
elif self.path=='/api/records':
with s.lock:self.respond({'emitters':s.records})
else:self.respond({'error':'not found'},404)
def do_POST(self):
try:
n=int(self.headers.get('Content-Length','0'))
if n<0 or n>8192:raise ValueError('request too large')
d=json.loads(self.rfile.read(n)) if n else {}
with s.lock:
if self.path=='/api/select':
ip=str(ipaddress.IPv4Address(d.get('ip',s.ip)))
if ipaddress.IPv4Address(ip).is_multicast or ip=='255.255.255.255' or ip=='0.0.0.0':raise ValueError('unicast controller IP required')
u=int(d.get('universe',s.universe))
if not 0<=u<=32767:raise ValueError('universe must be 0..32767')
s.off();s.ip=ip;s.universe=u;s.last=time.monotonic();self.respond(s.snapshot())
elif self.path=='/api/control':
ch=int(d.get('channel',s.channel));level=int(d.get('brightness',s.brightness))
if not 1<=ch<=512 or not 0<=level<=255:raise ValueError('channel 1..512, brightness 0..255')
if ch!=s.channel and s.enabled:s.off()
s.channel=ch;s.brightness=level;s.enabled=bool(d.get('enabled',False));s.last=time.monotonic()
if not s.enabled:s.off()
self.respond(s.snapshot())
elif self.path=='/api/record':
xyz=d.get('xyz');region=d.get('region','unknown')
if not isinstance(xyz,list) or len(xyz)!=3 or any(type(v) not in (int,float) or not math.isfinite(v) for v in xyz):raise ValueError('xyz must contain three finite numbers')
if region not in ('top','bottom','unknown'):raise ValueError('invalid region')
entry={'x':xyz[0],'y':xyz[1],'z':xyz[2],'region':region,'controller':s.ip,'universe':s.universe,'channel':s.channel,'notes':str(d.get('notes',''))[:200]}
if any((r['controller'],r['universe'],r['channel'])==(s.ip,s.universe,s.channel) for r in s.records):raise ValueError('channel already mapped')
if region!='unknown' and any((r['controller'],r['universe'],r['x'],r['y'],r['z'],r['region'])==(s.ip,s.universe,*xyz,region) for r in s.records):raise ValueError('region already mapped at these coordinates')
s.records.append(entry)
try:s.save()
except Exception:s.records.pop();raise
self.respond(entry)
elif self.path=='/api/delete':
i=int(d['index'])
if not 0<=i<len(s.records):raise ValueError('invalid record index')
s.records.pop(i);s.save();self.respond({'records':len(s.records)})
else:self.respond({'error':'not found'},404)
except (ValueError,TypeError,KeyError) as e:self.respond({'error':str(e)},400)
server=ThreadingHTTPServer((a.bind,a.port),Handler)
print(f'Open http://{a.bind}:{a.port} — select controller and universe in UI')
try:server.serve_forever()
except KeyboardInterrupt:pass
finally:
with s.lock:
s.off();s.off();s.off()
server.server_close()
if __name__=='__main__':main()