project
Adom Custom Electronic Load
Public Made by Adomby adom
Two-layer digitally controlled electronic-load prototype. Compact R11 is under active electrical, cooling and probe-access review; not a manufacturing release.
← Commit history
Add measured-power and arm-timeout protection; make fan feedback validity explicit in control panel
14 files changed
+629−3
control-panel/favicon.svg+1control-panel/index.html+26control-panel/loadctl.py+38control-panel/requirements.txt+1control-panel/server.py+292control-panel/transport.py+87docs/firmware-contract.md+9−1docs/placement-review.md+10firmware/load_core.c+7−1firmware/load_core.h+1−1kicad/adopt_usb_orientation.py+18tests/test_controller.py+62tests/test_load_core.c+12tests/test_transport.py+65control-panel/favicon.svgadded+1@@ -0,0 +1 @@+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="12" fill="#161b22"/><path d="M14 32h9l5-12 8 24 5-12h9" fill="none" stroke="#00b8b1" stroke-width="5" stroke-linejoin="round"/></svg>
control-panel/index.htmladded+26@@ -0,0 +1,26 @@+<!doctype html>+<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Adom Electronic Load</title><link rel="icon" href="favicon.svg">+<style>+:root{color-scheme:dark;font-family:Inter,system-ui,sans-serif;background:#0d1117;color:#e6edf3}*{box-sizing:border-box}body{margin:0}header{height:44px;display:flex;align-items:center;justify-content:space-between;padding:0 20px;border-bottom:1px solid #30363d;background:#161b22}header strong{font-size:14px}button,input{font:inherit}button{border:1px solid #39424e;border-radius:6px;background:#212933;color:#fff;padding:10px 15px;cursor:pointer}button:hover{border-color:#00b8b1}button:disabled{opacity:.4;cursor:not-allowed}main{max-width:1260px;margin:auto;padding:32px 28px}.intro{display:flex;align-items:start;justify-content:space-between;gap:20px}h1{font-weight:550;font-size:28px;margin:0 0 8px}p{line-height:1.6;color:#9ba8b9;margin:0 0 20px}.badge{font-size:12px;padding:7px 11px;border:1px solid #6b5533;border-radius:20px;color:#edc27f;white-space:nowrap}.metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin:24px 0}.card,.panel{background:#161b22;border:1px solid #30363d;border-radius:10px;padding:22px}.label{font-size:13px;color:#9ba8b9}.value{font:38px ui-monospace,monospace;margin-top:12px}.unit{font-size:16px;color:#9ba8b9}.layout{display:grid;grid-template-columns:1fr 1.7fr;gap:18px}h2{font-size:16px;margin:0 0 24px;font-weight:550}.number{width:100%;font:32px ui-monospace,monospace;background:#0d1117;color:#fff;border:1px solid #48515d;padding:12px;border-radius:6px;margin:10px 0 16px}input[type=range]{width:100%;accent-color:#00b8b1}.actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:22px}.primary{background:#007f7b}.off{background:#852d35;flex:1}.details{display:grid;grid-template-columns:1fr auto;gap:12px;margin-top:24px;font-size:13px}.details dt{color:#9ba8b9}.details dd{margin:0;font-family:ui-monospace,monospace}.empty{height:205px;display:grid;place-content:center;text-align:center;border:1px dashed #39424e;border-radius:6px;color:#9ba8b9;padding:20px}.empty strong{color:#d0d9e4;font-weight:500;margin-bottom:8px}.foot{display:flex;justify-content:space-between;margin-top:18px;gap:16px;font-size:12px;color:#9ba8b9}a{color:#4ad3cc}#notice{min-height:45px;margin-top:20px;padding:12px 16px;background:#1b222c;border-left:3px solid #00b8b1;border-radius:4px;font-size:13px}#events{font-size:12px;line-height:1.8;margin-top:16px;color:#9ba8b9;white-space:pre-wrap;max-height:150px;overflow:auto}dialog{background:#161b22;color:#fff;border:1px solid #39424e;border-radius:12px;padding:28px;max-width:520px;width:90%}dialog::backdrop{background:#0008}#settingsButton{border:0;padding:4px;background:none;font-size:20px}svg{width:100%;height:205px}#chart{display:none}.small{font-size:12px;color:#9ba8b9}.warning{color:#e2bd7f}button:focus-visible,input:focus-visible{outline:2px solid #00b8b1;outline-offset:3px}@media(max-width:760px){main{padding:24px 16px}.metrics{grid-template-columns:1fr 1fr}.layout{grid-template-columns:1fr}.intro{display:block}.badge{display:inline-block;margin-top:8px}.value{font-size:30px}}+</style></head><body>+<header><strong>Adom / Electronic load</strong><button id="settingsButton" aria-label="Settings" title="Connection and application settings">⚙</button></header>+<main><div class="intro"><div><h1>Constant-current load</h1><p>Set the load. Monitor the buck converter. Capture every measurement.</p></div><span class="badge" id="link">Disconnected</span></div>+<div class="metrics"><div class="card"><div class="label">DUT voltage</div><div class="value"><span id="volts">--</span> <span class="unit">V</span></div></div><div class="card"><div class="label">Measured current</div><div class="value"><span id="amps">--</span> <span class="unit">A</span></div></div><div class="card"><div class="label">Dissipated power</div><div class="value"><span id="watts">--</span> <span class="unit">W</span></div></div><div class="card"><div class="label">Case temperature</div><div class="value"><span id="case_c">--</span> <span class="unit">°C</span></div></div></div>+<div class="layout"><section class="panel"><h2>Load control</h2><label for="current" class="label">Requested current (A)</label><input id="current" class="number" type="number" min="0" max="1" step="0.01" value="0.10"><input id="slider" aria-label="Requested current" type="range" min="0" max="1" step="0.01" value="0.1"><div class="small">Initial buck profile: 0–1 A, maximum 5 W.</div><div class="actions"><button id="apply" disabled>Apply current</button><button id="arm" class="primary" disabled>Enable load</button><button id="off" class="off" disabled>Load off</button></div><dl class="details"><dt>Device state</dt><dd id="state">UNKNOWN</dd><dt>Applied demand</dt><dd id="demand">-- A</dd><dt>Fan speed</dt><dd id="rpm">-- rpm</dd><dt>Instrument</dt><dd id="serial">Not bound</dd><dt>Control link</dt><dd>USB / 3.3 V UART</dd></dl><button id="connect">Connect instrument</button><button id="clear" disabled>Clear fault</button></section>+<section class="panel"><h2>Measurement history</h2><div id="empty" class="empty"><strong>Waiting for the physical load</strong><span>Live readings appear after identity and OFF checks pass.<br>No simulated measurements are displayed.</span></div><svg id="chart" role="img" aria-label="Recent measured load power" viewBox="0 0 650 205"><path d="M40 10V175H640 M40 92H640" stroke="#30363d" fill="none"/><text x="4" y="16" fill="#9ba8b9" font-size="12">5 W</text><text x="6" y="178" fill="#9ba8b9" font-size="12">0 W</text><polyline id="trace" stroke="#00b8b1" stroke-width="2" fill="none"/></svg><div class="foot"><span>Measured power from device voltage × current</span><a href="api/export.csv">Export CSV</a></div><div id="events"></div></section></div>+<div id="notice" role="status">Engineering prototype. MCU port and physical qualification remain pending.</div><div class="foot"><span>Independent hardware shutdown + firmware watchdog are part of the design.</span><span class="warning">5 W bring-up ceiling</span></div></main>+<dialog id="settings"><h2>Settings</h2><div id="settingsText"></div><p class="small">The server binds an exact instrument path and serial at startup. Connection, load enable and current demand are never restored automatically.</p><label class="label" for="ttl">Idle shutdown (hours)</label><input id="ttl" class="number" type="number" min="0.1" max="168" value="24"><button id="saveSettings">Apply</button><button id="closeSettings">Close</button></dialog>+<script>+const $=s=>document.querySelector(s);let token='',state={},own=false,busy=false,lastUpdate=0,lastError=null;const caller='Load dashboard '+crypto.randomUUID().slice(0,8),history=[];+async function post(path,body){const r=await fetch(path,{method:'POST',headers:{'Content-Type':'application/json','X-Load-Token':token},body:JSON.stringify({aiThread:caller,...body})});const d=await r.json();if(!d.ok)throw Error(d.error||'Command rejected');return d}+function notice(s){$('#notice').textContent=s}+async function cmd(cmd,args={}){try{const d=await post('api/cmd',{cmd,...args});render(d.state);if(cmd==='arm')own=true;if(cmd==='off')own=false;notice(cmd==='off'?'Load OFF confirmed.':cmd==='arm'?(d.state.enabled?'Load enabled. Keep this control page active.':'Arm accepted; waiting for confirmed output enable.'):'Command accepted: '+cmd)}catch(e){own=false;notice(e.message)}}+function render(s){state=s;for(const k of ['volts','amps','watts','case_c'])$('#'+k).textContent=Number.isFinite(s[k])?s[k].toFixed(k==='case_c'?1:3):'--';$('#link').textContent=s.connected?'Connected / '+s.state:'Disconnected';$('#state').textContent=s.state;$('#demand').textContent=Number.isFinite(s.applied_a)?s.applied_a.toFixed(3)+' A':'-- A';$('#rpm').textContent=s.rpm_valid&&Number.isFinite(s.rpm)?Math.round(s.rpm)+' rpm':'Unknown / unqualified';$('#serial').textContent=s.serial||'Not bound';for(const id of ['apply','off'])$('#'+id).disabled=!s.connected;$('#arm').disabled=!s.connected||s.state!=='OFF'||!s.rpm_valid||s.rpm<5000||s.rpm>10000;$('#clear').disabled=!s.connected||s.state!=='FAULT';$('#connect').disabled=s.connected;if(!s.connected||s.state==='FAULT'||s.state==='OFF')own=false;if(s.updated_at&&s.updated_at!==lastUpdate&&Number.isFinite(s.watts)){lastUpdate=s.updated_at;history.push(s.watts);if(history.length>180)history.shift();$('#trace').setAttribute('points',history.map((v,i)=>(40+i*600/179)+','+(175-Math.max(0,Math.min(5,v))*33)).join(' '));$('#chart').style.display='block';$('#empty').style.display='none'}if(s.error&&s.error!==lastError)notice(s.error);lastError=s.error}+$('#slider').oninput=()=>$('#current').value=Number($('#slider').value).toFixed(2);$('#current').oninput=()=>$('#slider').value=$('#current').value;+$('#apply').onclick=()=>cmd('set_current',{amps:Number($('#current').value)});$('#arm').onclick=()=>cmd('arm');$('#off').onclick=()=>cmd('off');$('#clear').onclick=()=>cmd('clear_fault');$('#connect').onclick=()=>cmd('connect');+$('#settingsButton').onclick=async()=>{const s=await(await fetch('settings')).json();$('#ttl').value=s.ttl_hours;$('#settingsText').textContent=`Version ${s.version}. ${s.mode}. Device: ${s.device||'not configured'}.`;$('#settings').showModal()};$('#closeSettings').onclick=()=>$('#settings').close();$('#saveSettings').onclick=async()=>{try{await post('settings',{ttl_hours:Number($('#ttl').value)});notice('Idle shutdown setting saved.');$('#settings').close()}catch(e){notice(e.message)}};+fetch('bootstrap').then(r=>r.json()).then(d=>{token=d.token;const events=new EventSource('events');events.addEventListener('state',e=>render(JSON.parse(e.data)));events.onerror=()=>{own=false;notice('Dashboard link lost. Waiting for reconnect; load will not re-enable automatically.')}}).catch(e=>notice(e.message));+setInterval(async()=>{if(!own||busy||document.hidden)return;busy=true;try{await post('api/cmd',{cmd:'heartbeat'})}catch(e){own=false;notice(e.message)}finally{busy=false}},250);+document.addEventListener('visibilitychange',()=>{if(document.hidden&&own){own=false;cmd('off')}});window.addEventListener('pagehide',()=>{if(own)fetch('api/cmd',{method:'POST',keepalive:true,headers:{'Content-Type':'application/json','X-Load-Token':token},body:JSON.stringify({aiThread:caller,cmd:'off'})})});+setInterval(async()=>{try{const rows=await(await fetch('console')).json();$('#events').textContent=rows.slice(-6).map(r=>new Date(r.time*1000).toLocaleTimeString()+' '+r.text).join('\n')}catch{}},2000);+</script></body></html>
control-panel/loadctl.pyadded+38@@ -0,0 +1,38 @@+#!/usr/bin/env python3+"""Small CLI using the dashboard's own endpoints and per-thread registry."""+import argparse+import hashlib+import json+import os+from pathlib import Path+import subprocess+import urllib.request++p=argparse.ArgumentParser()+p.add_argument('--ai-thread',required=True)+p.add_argument('--target',help='Verified Adom Bridge desktop target; required only for show')+p.add_argument('action',choices=['ls','show','state','cmd','settings'])+p.add_argument('json',nargs='?',default='{}')+a=p.parse_args()+root=Path(__file__).resolve().parent+if a.action=='ls':+ raise SystemExit(subprocess.call(['python3',str(root/'server.py'),'--ai-thread',a.ai_thread,'ls']))+key=hashlib.sha256(a.ai_thread.encode()).hexdigest()[:16]+reg=Path.home()/'.local/state/adom-custom-load'/(key+'.json')+if not reg.exists():p.error('No instance; run server.py --ai-thread "'+a.ai_thread+'" first')+r=json.loads(reg.read_text());base='http://127.0.0.1:'+str(r['port'])+if a.action=='show':+ if not a.target:p.error('--target is required for showing the dashboard on a verified desktop')+ proxy=os.environ.get('VSCODE_PROXY_URI')+ if not proxy:p.error('No Hydrogen proxy; supply a verified host-visible URL through the Adom display tools')+ url=proxy.replace('{{port}}',str(r['port']))+ result=subprocess.call(['adom-bridge','--target',a.target,'--ai-thread',a.ai_thread,'pup_open_tab',json.dumps({'url':url})])+ raise SystemExit(result)+path={'state':'/state','cmd':'/api/cmd','settings':'/settings'}[a.action]+if a.action=='state' or (a.action=='settings' and a.json=='{}'):+ print(urllib.request.urlopen(base+path,timeout=2).read().decode());raise SystemExit()+token=json.load(urllib.request.urlopen(base+'/bootstrap',timeout=2))['token']+body=dict(json.loads(a.json),aiThread=a.ai_thread)+q=urllib.request.Request(base+path,data=json.dumps(body,allow_nan=False).encode(),headers={'Content-Type':'application/json','X-Load-Token':token})+try:print(urllib.request.urlopen(q,timeout=3).read().decode())+except urllib.error.HTTPError as e:print(e.read().decode());raise SystemExit(1)
control-panel/requirements.txtadded+1@@ -0,0 +1 @@+pyserial==3.5
control-panel/server.pyadded+292@@ -0,0 +1,292 @@+#!/usr/bin/env python3+"""Adom load companion dashboard. Disconnected by default; no simulated readings.++The serial adapter targets the documented draft protocol. A flashable MCU port+and physical qualification are still required before operating an assembled load.+"""+import argparse+import csv+import hashlib+import io+import json+import math+import os+from pathlib import Path+import secrets+import signal+import threading+import time+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer+from urllib.parse import urlparse+from transport import SerialLoad, LinkError++ROOT = Path(__file__).resolve().parent+VERSION = "0.0.2"+++class Controller:+ def __init__(self, device=None, serial=None):+ self.lock = threading.RLock()+ self.device, self.serial = device, serial+ self.link = None+ self.owner = None+ self.lease = 0.0+ self.rows = []+ self.events = []+ self.state = dict(connected=False, mode="disconnected", state="UNKNOWN",+ enabled=None, volts=None, amps=None, case_c=None,+ requested_a=0, applied_a=None, watts=None, rpm=None, rpm_valid=False,+ power_limit_w=5, serial=serial, error="No load connected",+ hardware_verified=False, updated_at=None)++ def event(self, text, caller="load monitor"):+ self.events.append(dict(time=time.time(), aiThread=caller, text=text))+ self.events[:] = self.events[-200:]++ def fail(self, exc):+ # A lost link cannot establish OFF. Try it once, then leave state unknown.+ if self.link:+ try: self.link.request("off")+ except Exception: pass+ try: self.link.close()+ except Exception: pass+ self.link, self.owner = None, None+ self.state.update(connected=False, mode="disconnected", enabled=None,+ state="UNKNOWN", error=str(exc), applied_a=None,+ volts=None, amps=None, watts=None, rpm=None, rpm_valid=False, case_c=None)+ self.event("Link lost; load state unknown: " + str(exc))++ def read(self):+ d = self.link.status()+ self.state.update({k:v for k,v in d.items() if k not in ("id", "ok", "boot", "owner")})+ self.state.update(connected=True, mode="hardware", error=None,+ watts=d["volts"] * d["amps"], updated_at=time.time())+ self.rows.append(dict(timestamp=time.time(), serial=self.serial, mode="hardware",+ volts=d["volts"], amps=d["amps"], watts=self.state["watts"],+ case_c=d["case_c"], rpm=d["rpm"], rpm_valid=d["rpm_valid"], state=d["state"]))+ self.rows[:] = self.rows[-7200:]++ def command(self, cmd, caller, **args):+ with self.lock:+ if cmd == "connect":+ if self.link: return self.snapshot()+ if not self.device or not self.serial:+ raise ValueError("Start with --device and --serial to bind an exact instrument")+ import serial+ stream = serial.Serial(self.device, baudrate=115200, timeout=.2,+ write_timeout=.2, exclusive=True)+ self.link = SerialLoad(stream, self.serial)+ try:+ self.link.connect()+ self.read()+ except Exception as exc:+ self.fail(exc)+ raise+ self.event("Connected and confirmed OFF", caller)+ return self.snapshot()+ if cmd == "off":+ self.owner = None+ if not self.link: raise ValueError("Disconnected: OFF cannot be confirmed")+ try:+ self.link.request("off")+ self.read()+ if self.state["enabled"] or self.state["dac_code"] != 0:+ raise LinkError("OFF readback failed")+ except Exception as exc:+ self.fail(exc)+ raise+ self.event("OFF confirmed", caller)+ return self.snapshot()+ if not self.link: raise ValueError("No connected load; connect and verify identity first")+ if self.owner and self.owner != caller:+ raise ValueError("Another controller owns the load; OFF remains available to everyone")+ if cmd not in ("set_current", "arm", "heartbeat", "clear_fault"):+ raise ValueError("Unknown command")+ if cmd == "set_current":+ a = args.get("amps")+ if type(a) not in (int, float) or not math.isfinite(a) or not 0 <= a <= 1:+ raise ValueError("The initial buck profile permits 0 to 1 A")+ args = dict(amps=a)+ else:+ args = {}+ try:+ if cmd == "arm":+ self.read()+ if not self.state.get("rpm_valid") or not 5000 <= self.state["rpm"] <= 10000:+ raise ValueError("Qualified fan feedback is required before enabling the load")+ self.link.request("heartbeat")+ self.link.request("arm")+ self.owner, self.lease = caller, time.monotonic()+ elif cmd == "heartbeat":+ if self.owner != caller: raise ValueError("Arm explicitly before renewing the lease")+ self.link.request("heartbeat")+ self.lease = time.monotonic()+ else:+ self.link.request(cmd, **args)+ self.read()+ except ValueError:+ raise+ except Exception as exc:+ self.fail(exc)+ raise+ if cmd != "heartbeat": self.event(cmd, caller)+ return self.snapshot()++ def poll(self):+ with self.lock:+ if not self.link: return+ try:+ if self.owner and time.monotonic() - self.lease > .75:+ self.link.request("off")+ self.owner = None+ self.event("Controller lease expired; requested OFF")+ self.read()+ if self.state["enabled"] or self.state["dac_code"]:+ raise LinkError("Lease-expiry OFF not confirmed")+ else: self.read()+ except Exception as exc: self.fail(exc)++ def snapshot(self):+ with self.lock:+ return dict(self.state, controller=self.owner, version=VERSION)+++def main():+ p = argparse.ArgumentParser()+ p.add_argument("action", choices=["start", "ls"], nargs="?", default="start")+ p.add_argument("--ai-thread", required=True)+ p.add_argument("--port", default="auto")+ p.add_argument("--device")+ p.add_argument("--serial")+ p.add_argument("--ttl", type=float, default=24)+ a = p.parse_args()+ if bool(a.device) != bool(a.serial): p.error("--device and --serial are required together")+ registry = Path.home()/".local/state/adom-custom-load"+ registry.mkdir(parents=True, exist_ok=True)+ key = hashlib.sha256(a.ai_thread.encode()).hexdigest()[:16]+ reg = registry/(key+".json")+ snapshot = registry/(key+".snapshot.json")+ if a.action == "ls":+ for f in registry.glob("*.json"):+ if f.name.endswith(".snapshot.json"): continue+ d = json.loads(f.read_text())+ try: os.kill(d["pid"], 0)+ except ProcessLookupError: f.unlink(); continue+ print(json.dumps(dict(d, version_drift=d["version"] != VERSION)))+ return+ if reg.exists():+ d = json.loads(reg.read_text())+ try: os.kill(d["pid"], 0)+ except ProcessLookupError: pass+ else:+ print("OK: already running " + json.dumps(d)); return+ c = Controller(a.device, a.serial)+ if snapshot.exists():+ previous = json.loads(snapshot.read_text())+ c.events = previous.get("events", [])[-200:]+ a.ttl = previous.get("ttl_hours", a.ttl)+ c.event("Restored history; load connection and enable are never restored")+ token = secrets.token_urlsafe(24)+ last_activity = [time.monotonic()]+ stop = threading.Event()+ def persist():+ with c.lock:+ temporary = snapshot.with_suffix(".tmp")+ temporary.write_text(json.dumps(dict(events=c.events,state=c.snapshot(),ttl_hours=a.ttl)))+ temporary.replace(snapshot)++ class Handler(BaseHTTPRequestHandler):+ def log_message(self, *args): pass+ def host_ok(self):+ host = self.headers.get("Host", "").split(":")[0].lower()+ proxy = os.environ.get("VSCODE_PROXY_URI", "").replace("{{port}}", str(server.server_port))+ allowed = {"localhost", "127.0.0.1", urlparse(proxy).hostname}+ if host in allowed: return True+ self.reply(dict(ok=False,error="Unrecognized dashboard host"),403)+ return False+ def reply(self, body, status=200, mime="application/json"):+ raw = body if isinstance(body, bytes) else json.dumps(body, allow_nan=False).encode()+ self.send_response(status)+ self.send_header("Content-Type", mime)+ self.send_header("Content-Length", str(len(raw)))+ self.send_header("Cache-Control", "no-store")+ self.send_header("X-Content-Type-Options", "nosniff")+ self.end_headers(); self.wfile.write(raw)+ def do_GET(self):+ if not self.host_ok(): return+ last_activity[0] = time.monotonic()+ path = urlparse(self.path).path+ if path == "/": return self.reply((ROOT/"index.html").read_bytes(), mime="text/html; charset=utf-8")+ if path == "/favicon.svg": return self.reply((ROOT/"favicon.svg").read_bytes(), mime="image/svg+xml")+ if path in ("/state", "/api/state"): return self.reply(c.snapshot())+ if path == "/bootstrap": return self.reply(dict(token=token, version=VERSION))+ if path == "/console": return self.reply(c.events)+ if path == "/settings": return self.reply(dict(ttl_hours=a.ttl, profile="5 V / 1 A buck, 5 W ceiling", serial=a.serial, device=a.device, version=VERSION, mode="Hardware only; disconnected until explicitly connected"))+ if path == "/api/export.csv":+ out = io.StringIO(); names=["timestamp", "serial", "mode", "volts", "amps", "watts", "case_c", "state"]+ w=csv.DictWriter(out, fieldnames=names);w.writeheader()+ with c.lock: w.writerows(c.rows)+ return self.reply(out.getvalue().encode(), mime="text/csv")+ if path == "/events":+ self.send_response(200)+ for k,v in {"Content-Type":"text/event-stream", "Cache-Control":"no-cache", "X-Accel-Buffering":"no"}.items():self.send_header(k,v)+ self.end_headers()+ try:+ self.wfile.write(b":"+b" "*2048+b"\n\n")+ while not stop.is_set():+ self.wfile.write(("event: state\ndata: "+json.dumps(c.snapshot())+"\n\n").encode());self.wfile.flush()+ stop.wait(.5)+ except (BrokenPipeError, ConnectionResetError): pass+ return+ self.reply(dict(ok=False,error="Not found"),404)+ def do_POST(self):+ if not self.host_ok(): return+ last_activity[0] = time.monotonic()+ if not secrets.compare_digest(self.headers.get("X-Load-Token", ""), token):+ return self.reply(dict(ok=False,error="Missing control token"),403)+ try:+ n=int(self.headers.get("Content-Length", "0"))+ if not 0<n<=4096: raise ValueError("Invalid request size")+ b=json.loads(self.rfile.read(n))+ caller=b.get("aiThread", "")+ if not isinstance(caller,str) or not caller.strip():+ return self.reply(dict(ok=False,errorCode="caller_identity_required",error="Pass aiThread in the request body"),400)+ path=urlparse(self.path).path+ if path == "/api/cmd":+ d=c.command(b.get("cmd"),caller,amps=b.get("amps"))+ if b.get("cmd") != "heartbeat":persist()+ return self.reply(dict(ok=True,state=d))+ if path == "/settings":+ if set(b)-{"aiThread","ttl_hours"}: raise ValueError("Only ttl_hours is editable")+ ttl=b.get("ttl_hours")+ if type(ttl) not in (int,float) or not math.isfinite(ttl) or not .1<=ttl<=168: raise ValueError("TTL must be 0.1 to 168 hours")+ a.ttl=ttl;persist();return self.reply(dict(ok=True,ttl_hours=ttl))+ raise ValueError("Unknown endpoint")+ except Exception as exc: self.reply(dict(ok=False,error=str(exc)),400)++ # Loopback only: use the authenticated Adom proxy or an SSH tunnel on a CM5.+ server=ThreadingHTTPServer(("127.0.0.1",0 if a.port=="auto" else int(a.port)),Handler)+ server.daemon_threads=True+ reg.write_text(json.dumps(dict(pid=os.getpid(),port=server.server_port,thread=a.ai_thread,version=VERSION,startedAt=time.time())))+ def monitor():+ while not stop.wait(.2):+ c.poll()+ if time.monotonic()-last_activity[0]>a.ttl*3600:+ server.shutdown();break+ threading.Thread(target=monitor,daemon=True).start()+ def shutdown(*_): threading.Thread(target=server.shutdown,daemon=True).start()+ signal.signal(signal.SIGTERM,shutdown);signal.signal(signal.SIGINT,shutdown)+ print(json.dumps(dict(ok=True,port=server.server_port,aiThread=a.ai_thread,mode="disconnected")),flush=True)+ try: server.serve_forever()+ finally:+ stop.set()+ with c.lock:+ if c.link:+ try:c.command("off","load shutdown")+ except Exception:pass+ if c.link:c.link.close()+ snapshot.write_text(json.dumps(dict(events=c.events,state=c.snapshot(),ttl_hours=a.ttl)))+ server.server_close();reg.unlink(missing_ok=True)+++if __name__ == "__main__": main()
control-panel/transport.pyadded+87@@ -0,0 +1,87 @@+"""Bounded serial transport for the proposed adom.load/1 firmware protocol.++No automatic reconnect or replay of an arm/current request. This is a host+implementation, not evidence that a physical MCU implements the protocol.+"""+import json+import math+import secrets+++class LinkError(RuntimeError):+ pass+++class SerialLoad:+ def __init__(self, stream, serial):+ self.stream, self.serial = stream, serial+ self.seq = 0+ self.boot = None+ self.owner = secrets.token_hex(12)++ def request(self, cmd, **fields):+ self.seq += 1+ packet = dict(id=self.seq, cmd=cmd, **fields)+ if self.boot is not None:+ packet.update(boot=self.boot, owner=self.owner)+ wire = json.dumps(packet, separators=(",", ":"), allow_nan=False).encode() + b"\n"+ if len(wire) > 256:+ raise LinkError("Request exceeds firmware frame limit")+ try:+ if self.stream.write(wire) != len(wire):+ raise LinkError("Incomplete serial write; command outcome unknown")+ raw = self.stream.read_until(b"\n", 2049)+ if not raw.endswith(b"\n") or len(raw) > 2048:+ raise LinkError("Missing or oversized reply; command outcome unknown")+ def reject_constant(value):+ raise ValueError("Non-finite reply: " + value)+ result = json.loads(raw, parse_constant=reject_constant)+ if not isinstance(result, dict) or type(result.get("id")) is not int or result["id"] != self.seq:+ raise LinkError("Reply sequence mismatch; command outcome unknown")+ if result.get("ok") is not True:+ raise LinkError(str(result.get("error", "Device rejected command")))+ if self.boot is not None and result.get("boot") != self.boot:+ raise LinkError("Device reset; explicit reconnect required")+ return result+ except (OSError, ValueError, TypeError) as exc:+ raise LinkError(str(exc)) from exc++ def connect(self):+ d = self.request("identify")+ if d.get("protocol") != "adom.load/1" or d.get("serial") != self.serial:+ raise LinkError("Wrong instrument identity or unsupported protocol")+ if not isinstance(d.get("boot"), str) or not 8 <= len(d["boot"]) <= 32:+ raise LinkError("Missing boot identity")+ self.boot = d["boot"]+ self.request("claim")+ self.request("off")+ state = self.status()+ if state["enabled"] or state["dac_code"] != 0:+ raise LinkError("Startup OFF was not confirmed")+ return state++ def status(self):+ d = self.request("status")+ for key in ("volts", "amps", "case_c", "requested_a", "applied_a", "power_limit_w", "sample_age_ms"):+ value = d.get(key)+ if type(value) not in (float, int) or not math.isfinite(value):+ raise LinkError("Invalid telemetry: " + key)+ if type(d.get("rpm_valid")) is not bool:+ raise LinkError("Missing fan feedback validity")+ if d["rpm_valid"]:+ if type(d.get("rpm")) not in (int, float) or not math.isfinite(d["rpm"]) or not 0 <= d["rpm"] <= 10000:+ raise LinkError("Invalid fan RPM")+ elif d.get("rpm") is not None:+ raise LinkError("Unqualified fan feedback must use null RPM")+ if d.get("state") not in ("OFF", "ARMED", "RUNNING", "FAULT") or type(d.get("enabled")) is not bool:+ raise LinkError("Invalid device state")+ if type(d.get("dac_code")) is not int or not 0 <= d["dac_code"] <= 4095:+ raise LinkError("Invalid DAC telemetry")+ if not 0 <= d["sample_age_ms"] <= 50:+ raise LinkError("Device sensor sample is stale")+ if not 0 < d["power_limit_w"] <= 5:+ raise LinkError("This bring-up adapter requires a device ceiling of at most 5 W")+ return d++ def close(self):+ self.stream.close()
docs/firmware-contract.md+9−1@@ -4,7 +4,7 @@ The portable C safety-policy core is implemented and host-tested. There is no fl USB CDC ACM is the preferred link to the control panel. The board is self-powered from auxiliary 5 V; USB VBUS is detected but does not power the fan or logic. D+/D− and ground connect to the panel USB host. UART at 3.3 V is an alternative through the molecule contacts; it is not a power interface and must not be wired to 5 V GPIO. Never enable a load by connecting a raw GPIO wire alone. -Proposed transport: newline-delimited JSON, protocol `adom.load/1`, maximum 256 bytes per line, explicit request ID and success/error response. Commands: `identify`, `status`, `set_current` (amperes), `arm`, `off`, `clear_fault`, `heartbeat`. One exclusive controlling session; monotonic sequence numbers; stale/replayed commands rejected. Disconnect, parser overflow, invalid framing or heartbeat loss disables the load. `off` must have priority over queued commands. The panel must show requested and applied current separately.+Proposed transport: newline-delimited JSON, protocol `adom.load/1`, maximum 256 bytes per line, explicit request ID and success/error response. Commands: `identify`, `claim`, `status`, `set_current` (amperes), `arm`, `off`, `clear_fault`, `heartbeat`. One exclusive controlling session; monotonic sequence numbers; stale/replayed commands rejected. Disconnect, parser overflow, invalid framing or heartbeat loss disables the load. `off` must have priority over queued commands. The panel must show requested and applied current separately. Every boot starts disabled: configure MCU_ENABLE low and ARM_CLK low before peripheral initialization, keep DAC at zero, and verify the hardware clear/clamp path. `arm` is allowed only with fresh valid telemetry, healthy fan, safe temperature/voltage, a verified assembly configuration and a live host lease. Write DAC zero, pulse ARM_CLK, verify LATCH_Q, then enable; missing latch acknowledgement must time out. Fault clearing never automatically re-arms. @@ -17,3 +17,11 @@ Telemetry should include DUT voltage/current/power, case temperature, tach RPM, Host test: `gcc -std=c11 -Wall -Wextra -Werror -fsanitize=address,undefined firmware/load_core.c tests/test_load_core.c -lm -o /tmp/test-adom-load && /tmp/test-adom-load`. The test covers rejected nonfinite/range commands, bring-up power limiting, immediate downward limiting, hardware fault latching, clear/re-arm separation, stale samples, nonfinite sensors, host loss and 32-bit clock wrap. It does not validate the MCU binary, USB transport, actual pin polarity or physical watchdog response. Register drivers in `firmware/instruments.c` explicitly set DAC GAIN=0x0101: divide the internal reference by two and use buffer gain two. This is necessary on the 3.3 V supply; divide-disabled operation with a 2.5 V reference violates the DAC reference headroom requirement. Exact DAC identity, gain readback, reference alarm and INA226 identity are checked before enabling. Register drivers compile with strict warnings, but I2C operation is untested on hardware.++## Protocol and protection updates — 2026-10-08++After identify verifies protocol and exact serial number, the host binds a boot nonce and a random owner token through claim, requests OFF and checks readback. Later requests carry the boot nonce and owner token. A changed boot identity requires an explicit reconnect; no arm/current command is automatically replayed.++Fan telemetry includes mandatory `rpm_valid`. Qualified RPM is finite and in range; unqualified feedback uses `rpm:null,rpm_valid:false`, never a fabricated zero. The UI labels it unknown and refuses arm; the device core independently requires 5000–10000 RPM. The host CSV preserves RPM and validity.++The core now faults on measured power above 1.10 times the configured 5 W ceiling, independently of its setpoint clamp. The 10% measurement allowance is provisional and requires calibration/transient validation. Invalid or unqualified higher power ceilings are refused. An arm request without hardware-latch acknowledgement faults after 100 ms, even if host heartbeats continue. Host tests cover both paths and clock wrap; no physical shutdown latency is claimed.
docs/placement-review.md+10@@ -1,3 +1,13 @@+# Latest placement checkpoint — 2026-10-08++The adopted canonical board now has 104 components and 52 nets. It has zero native DRC violations and 220 unrouted connections, using the same reviewed rule file as the candidate. All nine planned measurements have probe access. TP12 exposes fan tach; TP13 exposes Kelvin minus. The two USB series resistors were rotated live to put their MCU-side pads toward the MCU.++Rotating them also moved their reference labels. KiCad 10.0.5 rejected native field updates with `native_field_update_unsupported` and reported rollback with no mutation. The two labels were restored with native pcbnew on a saved copy, then the exact board was reopened. This correction is not represented as a live field edit. The initial canonical-path check exposed an older accompanying rule file; carrying the unchanged reviewed candidate rules restored the correct comparison. No electrical rule was relaxed for the rotation.++AIFlow's placement confirmation reapplies the two already accepted poses as an idempotent verification; it is not new placement footage. The original live rotation and saved-file correction are recorded separately. Native model resolution/orientation checks pass, but manufacturer-level geometry, cooling and assembled probe clearance remain pending.++## Earlier placement review+ # R0 placement review The latest `kicad/placement-candidate.kicad_pcb` contains all 102 components on the molecule board. Native KiCad reports **zero DRC violations and 218 unconnected items** in `kicad/candidate-drc.json`. This is a placement checkpoint, not a routed board or fabrication release.
firmware/load_core.c+7−1@@ -16,6 +16,11 @@ static uint32_t checks(const load_core *c,const load_sample *s){ if(age(s->now_ms,c->lease_ms)>1000) f|=F_LINK; if(s->case_c>=55 || s->case_c< -20) f|=F_THERMAL; if(s->volts<4.5f || s->volts>24 || s->amps< -0.02f || s->amps>3.15f) f|=F_ENVELOPE;+ /* Independent measured-power guard. 10% provisional tolerance above the 5 W+ command ceiling avoids equating a command clamp with actual power control.+ Validate measurement error and shutdown latency on the assembled hardware. */+ if(!isfinite(c->power_limit_w) || c->power_limit_w<=0 || c->power_limit_w>5 ||+ s->volts*s->amps>c->power_limit_w*1.10f) f|=F_ENVELOPE; return f; } void load_init(load_core *c,uint32_t now){memset(c,0,sizeof(*c));c->state=LOAD_OFF;c->last_tick_ms=now;c->lease_ms=now;c->power_limit_w=5;}@@ -24,7 +29,7 @@ bool load_set_current(load_core *c,float a){if(!isfinite(a)||a<0||a>3)return fal void load_heartbeat(load_core *c,uint32_t now){c->lease_ms=now;} bool load_arm(load_core *c,const load_sample *s){ if(c->state!=LOAD_OFF || !s->assembly_qualified || checks(c,s))return false;- c->state=LOAD_ARMED;c->last_tick_ms=s->now_ms;return true;+ c->state=LOAD_ARMED;c->last_tick_ms=s->now_ms;c->arm_started_ms=s->now_ms;return true; } bool load_clear_fault(load_core *c,const load_sample *s){ if(c->state!=LOAD_FAULT || checks(c,s) || s->latch_q)return false;@@ -36,6 +41,7 @@ void load_tick(load_core *c,const load_sample *s){ uint32_t f=checks(c,s); if(!s->assembly_qualified || (c->state==LOAD_RUNNING && !s->latch_q))f|=F_HARDWARE; if(dt>50)f|=F_SENSOR;+ if(c->state==LOAD_ARMED && age(s->now_ms,c->arm_started_ms)>=100)f|=F_HARDWARE; if(f){c->faults|=f;c->state=LOAD_FAULT;load_off(c);return;} /* ARM only prepares the state. Adapter holds enable low, DAC zero, pulses ARM_CLK, reads LATCH_Q, then calls tick. It must time out an unacknowledged arm. */
firmware/load_core.h+1−1@@ -12,7 +12,7 @@ typedef struct { bool sensors_valid, hw_ok, latch_q, assembly_qualified; } load_sample; typedef struct {- load_state state; uint32_t faults, last_tick_ms, lease_ms;+ load_state state; uint32_t faults, last_tick_ms, lease_ms, arm_started_ms; float requested_a, applied_a, power_limit_w; uint16_t dac_code; bool enable; } load_core;
kicad/adopt_usb_orientation.pyadded+18@@ -0,0 +1,18 @@+"""Saved-file reference correction after native resistor rotations; not live IPC."""+from pathlib import Path+import pcbnew as k,shutil,json+p=Path(__file__).resolve().parent+source=p/'placement-candidate.kicad_pcb';target=p/'adom-custom-electronic-load.kicad_pcb'+backup=p/'before-usb-orientation-adoption.kicad_pcb'+if backup.exists():raise RuntimeError('Checkpoint exists; inspect before rerunning')+shutil.copy2(target,backup)+b=k.LoadBoard(str(source));found=[]+for fp in b.GetFootprints():+ if fp.GetReference() in ('R31','R32'):+ assert abs(fp.GetOrientationDegrees()-180)<.001+ x,y=(149.7,43.405) if fp.GetReference()=='R31' else (152.655,46.5)+ fp.Reference().SetPosition(k.VECTOR2I(k.FromMM(x),k.FromMM(y)))+ fp.Reference().SetTextAngle(k.EDA_ANGLE(0,k.DEGREES_T));found.append(fp.GetReference())+assert len(found)==2+k.SaveBoard(str(target),b)+print(json.dumps({'saved':str(target),'changed_fields':found,'footprints':len(list(b.GetFootprints())),'tracks':len(list(b.GetTracks())),'mechanism':'native pcbnew saved-file correction; must reload editor'}))
tests/test_controller.pyadded+62@@ -0,0 +1,62 @@+import sys+import time+import unittest+from pathlib import Path+sys.path.insert(0, str(Path(__file__).resolve().parents[1]/"control-panel"))+from server import Controller+++class FakeLink:+ def __init__(self):+ self.calls=[]; self.on=False; self.fail_reads=False; self.ignore_off=False+ def request(self, cmd, **args):+ self.calls.append(cmd)+ if cmd == 'arm':self.on=True+ if cmd == 'off' and not self.ignore_off:self.on=False+ return {'ok':True}+ def status(self):+ if self.fail_reads:raise IOError('Cable removed')+ return dict(volts=5, amps=.1 if self.on else 0, case_c=25,+ enabled=self.on, dac_code=130 if self.on else 0,+ state='RUNNING' if self.on else 'OFF',requested_a=.1,+ applied_a=.1 if self.on else 0, rpm=7000, rpm_valid=True, power_limit_w=5)+ def close(self):pass+++class ControlTests(unittest.TestCase):+ def setUp(self):+ self.c=Controller();self.link=FakeLink();self.c.link=self.link+ def test_no_invented_readings_when_disconnected(self):+ s=Controller().snapshot()+ self.assertFalse(s['connected']);self.assertIsNone(s['volts'])+ def test_one_owner_but_any_caller_can_stop(self):+ self.c.command('arm','operator A')+ with self.assertRaises(ValueError):self.c.command('set_current','operator B',amps=.5)+ self.c.command('off','operator B')+ self.assertFalse(self.c.snapshot()['enabled'])+ def test_controller_disappears_requests_off(self):+ self.c.command('arm','operator A');self.c.lease=time.monotonic()-1+ self.c.poll()+ self.assertEqual(self.link.calls[-1],'off')+ self.assertFalse(self.c.snapshot()['enabled']);self.assertIsNone(self.c.owner)+ def test_lost_cable_means_unknown_not_off(self):+ self.c.command('arm','operator A');self.link.fail_reads=True;self.c.poll()+ self.assertIsNone(self.c.snapshot()['enabled'])+ self.assertEqual(self.c.snapshot()['state'],'UNKNOWN')+ self.assertIsNone(self.c.link)+ def test_ignored_off_disconnects_and_reports_unknown(self):+ self.c.command('arm','operator A');self.link.ignore_off=True+ with self.assertRaises(Exception):self.c.command('off','operator A')+ self.assertIsNone(self.c.snapshot()['enabled'])+ def test_unqualified_fan_cannot_send_arm(self):+ old=self.link.status+ self.link.status=lambda: dict(old(),rpm=None,rpm_valid=False)+ with self.assertRaises(ValueError):self.c.command('arm','operator A')+ self.assertNotIn('arm',self.link.calls)+ def test_invalid_setpoints_do_not_reach_device(self):+ for a in [float('nan'),float('inf'),True,-.1,1.1]:+ with self.assertRaises(ValueError):self.c.command('set_current','operator A',amps=a)+ self.assertEqual(self.link.calls,[])+++if __name__=='__main__':unittest.main()
tests/test_load_core.c+12@@ -34,5 +34,17 @@ int main(void){ load_init(&c,0);s=good(0);s.fan_rpm_valid=false;assert(!load_arm(&c,&s)); load_init(&c,0); load_off(&c);assert(c.state==LOAD_OFF&&!c.enable&&c.requested_a==0);+ /* An analog-loop fault can violate actual power despite a low requested current. */+ load_init(&c,0);s=good(0);assert(load_arm(&c,&s));+ s=good(10);s.volts=24;s.amps=1;load_tick(&c,&s);+ assert(c.state==LOAD_FAULT&&(c.faults&F_ENVELOPE)&&!c.enable&&!c.dac_code);+ load_init(&c,0);s=good(0);s.amps=1.09f;assert(load_arm(&c,&s));+ s=good(10);s.amps=1.11f;load_tick(&c,&s);assert(c.faults&F_ENVELOPE);+ /* Fresh heartbeats cannot keep an unacknowledged arm waiting indefinitely. */+ load_init(&c,UINT32_MAX-50);s=good(UINT32_MAX-50);s.latch_q=false;assert(load_arm(&c,&s));+ for(unsigned dt=10;dt<=100;dt+=10){s=good((UINT32_MAX-50)+dt);s.latch_q=false;load_heartbeat(&c,s.now_ms);load_tick(&c,&s);}+ assert(c.state==LOAD_FAULT&&(c.faults&F_HARDWARE)&&!c.enable&&!c.dac_code);+ load_init(&c,0);s=good(0);c.power_limit_w=NAN;assert(!load_arm(&c,&s));+ c.power_limit_w=25;assert(!load_arm(&c,&s)); /* Unqualified raised ceiling is refused. */ puts("load_core: safety policy tests passed (host C build; no MCU/HAL validation)"); }
tests/test_transport.pyadded+65@@ -0,0 +1,65 @@+"""Protocol failure tests with an in-memory instrument, never physical hardware."""+import json+import sys+import unittest+from pathlib import Path+sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "control-panel"))+from transport import SerialLoad, LinkError+++class FakeWire:+ def __init__(self):+ self.sent = []+ self.change = lambda d: d+ def write(self, raw):+ self.sent.append(json.loads(raw))+ return len(raw)+ def read_until(self, terminator, size):+ q = self.sent[-1]+ d = dict(id=q["id"], ok=True, protocol="adom.load/1", serial="LOAD-TEST",+ boot="boot0001", enabled=False, state="OFF", dac_code=0,+ volts=5, amps=0, case_c=25, requested_a=0, applied_a=0,+ power_limit_w=5, sample_age_ms=10, rpm=7000, rpm_valid=True)+ return json.dumps(self.change(d)).encode() + b"\n"+ def close(self):+ pass+++class TransportTests(unittest.TestCase):+ def setUp(self):+ self.wire = FakeWire()+ self.load = SerialLoad(self.wire, "LOAD-TEST")+ def test_startup_confirms_identity_claim_and_off(self):+ self.load.connect()+ self.assertEqual([q["cmd"] for q in self.wire.sent], ["identify", "claim", "off", "status"])+ self.assertNotIn("arm", [q["cmd"] for q in self.wire.sent])+ def test_wrong_identity_never_claims(self):+ self.load.serial = "ANOTHER"+ with self.assertRaises(LinkError): self.load.connect()+ self.assertEqual(len(self.wire.sent), 1)+ def test_stale_reply_not_retried(self):+ self.wire.change = lambda d: dict(d, id=0)+ with self.assertRaises(LinkError): self.load.request("arm")+ self.assertEqual(len(self.wire.sent), 1)+ def test_reset_detected(self):+ self.load.connect()+ self.wire.change = lambda d: dict(d, boot="boot0002")+ with self.assertRaises(LinkError): self.load.status()+ def test_nonfinite_and_stale_measurements(self):+ self.load.connect()+ for change in ({"amps":float("nan")}, {"sample_age_ms":51}, {"sample_age_ms":-1}, {"volts":True}, {"power_limit_w":25}):+ self.wire.change = lambda d: dict(d, **change)+ with self.assertRaises(LinkError): self.load.status()+ def test_unqualified_rpm_is_explicitly_unknown(self):+ self.load.connect()+ self.wire.change = lambda d: dict(d, rpm=None, rpm_valid=False)+ self.assertIsNone(self.load.status()["rpm"])+ for change in ({"rpm_valid":None}, {"rpm":7000,"rpm_valid":False}, {"rpm":-1}, {"rpm":10001}):+ self.wire.change = lambda d: dict(d, **change)+ with self.assertRaises(LinkError): self.load.status()+ def test_on_readback_refused(self):+ self.wire.change = lambda d: dict(d, enabled=True)+ with self.assertRaises(LinkError): self.load.connect()+++if __name__ == "__main__": unittest.main()