120 lines
6.7 KiB
Python
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()
|