generated from ztimson/template
New UI
This commit is contained in:
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"shutterUs": 0,
|
||||||
|
"gain": 0,
|
||||||
|
"ev": -0.04,
|
||||||
|
"exposureMode": "long",
|
||||||
|
"brightness": 0,
|
||||||
|
"contrast": 1,
|
||||||
|
"saturation": 1,
|
||||||
|
"sharpness": 1,
|
||||||
|
"awb": "fluorescent",
|
||||||
|
"awbGains": null,
|
||||||
|
"denoise": "off",
|
||||||
|
"autofocusMode": "manual",
|
||||||
|
"autofocusRange": "macro",
|
||||||
|
"autofocusSpeed": "normal",
|
||||||
|
"lensPosition": 4.8,
|
||||||
|
"hflip": false,
|
||||||
|
"vflip": false,
|
||||||
|
"rotation": 0,
|
||||||
|
"quality": 93,
|
||||||
|
"roi": null
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 2.6 MiB |
@@ -0,0 +1,86 @@
|
|||||||
|
{
|
||||||
|
"type": "photo",
|
||||||
|
"name": "photo_20261022T003029Z.jpg",
|
||||||
|
"url": "/media/photos/photo_20261022T003029Z.jpg",
|
||||||
|
"raw": null,
|
||||||
|
"bytes": 2766941,
|
||||||
|
"durationMs": 4318,
|
||||||
|
"takenAt": "2026-10-22T00:30:33.571Z",
|
||||||
|
"settings": {
|
||||||
|
"shutterUs": 0,
|
||||||
|
"gain": 0,
|
||||||
|
"ev": 0,
|
||||||
|
"exposureMode": "normal",
|
||||||
|
"brightness": 0,
|
||||||
|
"contrast": 1,
|
||||||
|
"saturation": 1,
|
||||||
|
"sharpness": 1,
|
||||||
|
"awb": "auto",
|
||||||
|
"awbGains": null,
|
||||||
|
"denoise": "auto",
|
||||||
|
"autofocusMode": "manual",
|
||||||
|
"autofocusRange": "normal",
|
||||||
|
"autofocusSpeed": "normal",
|
||||||
|
"lensPosition": 0,
|
||||||
|
"hflip": false,
|
||||||
|
"vflip": false,
|
||||||
|
"rotation": 0,
|
||||||
|
"quality": 93,
|
||||||
|
"roi": null,
|
||||||
|
"iso": 0
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"telemetry": {
|
||||||
|
"time": {
|
||||||
|
"iso": "2026-10-22T00:30:29.252Z",
|
||||||
|
"epochMs": 1792629029252
|
||||||
|
},
|
||||||
|
"sensor": {
|
||||||
|
"state": "backoff",
|
||||||
|
"sampleAgeMs": null,
|
||||||
|
"restarts": 6,
|
||||||
|
"lastError": "exited (code=1, signal=null)"
|
||||||
|
},
|
||||||
|
"orientation": null,
|
||||||
|
"battery": {
|
||||||
|
"state": "ok",
|
||||||
|
"available": true,
|
||||||
|
"source": "pisugar-server",
|
||||||
|
"socketPath": "/tmp/pisugar-server.sock",
|
||||||
|
"voltageV": 3.874,
|
||||||
|
"percent": 85,
|
||||||
|
"percentEstimate": 85,
|
||||||
|
"percentIsEstimate": false,
|
||||||
|
"powerPlugged": true,
|
||||||
|
"chargingAllowed": true,
|
||||||
|
"charging": true,
|
||||||
|
"updatedAt": "2026-10-22T00:30:25.646Z",
|
||||||
|
"error": null
|
||||||
|
},
|
||||||
|
"gps": {
|
||||||
|
"state": "no_fix",
|
||||||
|
"position": null,
|
||||||
|
"satellites": {
|
||||||
|
"used": 0,
|
||||||
|
"seen": 26
|
||||||
|
},
|
||||||
|
"dop": {
|
||||||
|
"hdop": null,
|
||||||
|
"vdop": null,
|
||||||
|
"pdop": null
|
||||||
|
},
|
||||||
|
"speedMps": null,
|
||||||
|
"clockSkewMs": -609,
|
||||||
|
"errorM": {
|
||||||
|
"x": null,
|
||||||
|
"y": null,
|
||||||
|
"v": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pointing": {
|
||||||
|
"available": false,
|
||||||
|
"error": "no_orientation"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 3.9 MiB |
@@ -0,0 +1,86 @@
|
|||||||
|
{
|
||||||
|
"type": "photo",
|
||||||
|
"name": "photo_20261022T005834Z.jpg",
|
||||||
|
"url": "/media/photos/photo_20261022T005834Z.jpg",
|
||||||
|
"raw": null,
|
||||||
|
"bytes": 4131974,
|
||||||
|
"durationMs": 3962,
|
||||||
|
"takenAt": "2026-10-22T00:58:38.129Z",
|
||||||
|
"settings": {
|
||||||
|
"shutterUs": 0,
|
||||||
|
"gain": 5,
|
||||||
|
"ev": 0,
|
||||||
|
"exposureMode": "long",
|
||||||
|
"brightness": 0,
|
||||||
|
"contrast": 1,
|
||||||
|
"saturation": 1,
|
||||||
|
"sharpness": 1,
|
||||||
|
"awb": "indoor",
|
||||||
|
"awbGains": null,
|
||||||
|
"denoise": "off",
|
||||||
|
"autofocusMode": "continuous",
|
||||||
|
"autofocusRange": "macro",
|
||||||
|
"autofocusSpeed": "normal",
|
||||||
|
"lensPosition": 0.4,
|
||||||
|
"hflip": false,
|
||||||
|
"vflip": false,
|
||||||
|
"rotation": 0,
|
||||||
|
"quality": 93,
|
||||||
|
"roi": null,
|
||||||
|
"iso": 500
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"telemetry": {
|
||||||
|
"time": {
|
||||||
|
"iso": "2026-10-22T00:58:34.160Z",
|
||||||
|
"epochMs": 1792630714160
|
||||||
|
},
|
||||||
|
"sensor": {
|
||||||
|
"state": "backoff",
|
||||||
|
"sampleAgeMs": null,
|
||||||
|
"restarts": 11,
|
||||||
|
"lastError": "exited (code=1, signal=null)"
|
||||||
|
},
|
||||||
|
"orientation": null,
|
||||||
|
"battery": {
|
||||||
|
"state": "ok",
|
||||||
|
"available": true,
|
||||||
|
"source": "pisugar-server",
|
||||||
|
"socketPath": "/tmp/pisugar-server.sock",
|
||||||
|
"voltageV": 3.893,
|
||||||
|
"percent": 87,
|
||||||
|
"percentEstimate": 87,
|
||||||
|
"percentIsEstimate": false,
|
||||||
|
"powerPlugged": true,
|
||||||
|
"chargingAllowed": true,
|
||||||
|
"charging": true,
|
||||||
|
"updatedAt": "2026-10-22T00:58:31.250Z",
|
||||||
|
"error": null
|
||||||
|
},
|
||||||
|
"gps": {
|
||||||
|
"state": "no_fix",
|
||||||
|
"position": null,
|
||||||
|
"satellites": {
|
||||||
|
"used": 0,
|
||||||
|
"seen": 20
|
||||||
|
},
|
||||||
|
"dop": {
|
||||||
|
"hdop": null,
|
||||||
|
"vdop": null,
|
||||||
|
"pdop": null
|
||||||
|
},
|
||||||
|
"speedMps": null,
|
||||||
|
"clockSkewMs": -80,
|
||||||
|
"errorM": {
|
||||||
|
"x": null,
|
||||||
|
"y": null,
|
||||||
|
"v": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pointing": {
|
||||||
|
"available": false,
|
||||||
|
"error": "no_orientation"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+206
@@ -0,0 +1,206 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en"><head><meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>MRS Telescope</title>
|
||||||
|
<style>
|
||||||
|
:root{--bg:#06080e;--card:#0c111d;--line:#1b2440;--tx:#e6ebf7;--mut:#7f8aa8;--ac:#2f7bff;--ok:#3ddc84;--bad:#ff5c6c;--warn:#ffc247}
|
||||||
|
*{box-sizing:border-box}
|
||||||
|
body{margin:0;background:var(--bg);color:var(--tx);font:13px system-ui,sans-serif;height:100vh;display:grid;grid-template-columns:auto 1fr 1fr;gap:6px;padding:6px;overflow:hidden}
|
||||||
|
#view{position:relative;height:calc(100vh - 12px);aspect-ratio:1;background:#000;border:1px solid var(--line);border-radius:10px;overflow:hidden}
|
||||||
|
#view img,#ov{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}
|
||||||
|
.hud{position:absolute;background:#060a14cc;border:1px solid var(--line);border-radius:8px;padding:6px 10px;font-variant-numeric:tabular-nums;backdrop-filter:blur(4px)}
|
||||||
|
#warn{position:absolute;left:50%;top:45%;transform:translate(-50%,-50%);text-align:center;background:#1a1208ee;border:1px solid var(--warn);color:var(--warn);border-radius:10px;padding:14px 18px;max-width:80%;display:none}
|
||||||
|
#pip{position:absolute;top:10px;right:10px;width:210px;height:210px;border:1px solid var(--line);border-radius:50%;background:#060a14dd}
|
||||||
|
#ovc{position:absolute;bottom:10px;left:50%;transform:translateX(-50%);display:flex;gap:4px;padding:4px 6px}
|
||||||
|
.col{display:flex;flex-direction:column;gap:6px;overflow:auto;scrollbar-width:none;min-width:0}.col::-webkit-scrollbar{display:none}
|
||||||
|
section{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:10px 12px;flex:none}
|
||||||
|
h3{margin:0 0 8px;font-size:12px;letter-spacing:.08em;text-transform:uppercase;color:var(--mut);display:flex;justify-content:space-between}
|
||||||
|
h3 small{text-transform:none;letter-spacing:0}
|
||||||
|
input,button{font:inherit;color:var(--tx)}
|
||||||
|
input[type=text],input[type=number]{background:#111829;border:1px solid var(--line);border-radius:6px;padding:5px 7px;width:100%}
|
||||||
|
button{background:#121a2e;border:1px solid var(--line);border-radius:6px;padding:5px 9px;cursor:pointer}
|
||||||
|
button:hover:not(:disabled){border-color:var(--ac)}button:disabled{opacity:.35;cursor:not-allowed}
|
||||||
|
button.p{background:var(--ac);border-color:var(--ac)}
|
||||||
|
.chip{border-radius:99px;padding:3px 10px;font-size:12px}.chip.on,.segs button.on{background:var(--ac);border-color:var(--ac)}
|
||||||
|
.segs{display:flex;gap:3px}.segs button{flex:1;padding:4px 2px;font-size:12px}
|
||||||
|
.ctl{margin:8px 0}.lh{display:flex;justify-content:space-between;color:var(--mut);margin-bottom:4px}.lh b{color:var(--tx);font-weight:600}
|
||||||
|
input[type=range]{width:100%;accent-color:var(--ac);height:18px;margin:0}
|
||||||
|
#cam.af input[data-k=lensPosition]{opacity:.35;pointer-events:none}
|
||||||
|
details summary{cursor:pointer;color:var(--mut);margin:8px 0}.flips{display:flex;gap:4px;flex-wrap:wrap}
|
||||||
|
.g2{display:grid;grid-template-columns:1fr 1fr;gap:6px}.tile{background:#0f1626;border:1px solid var(--line);border-radius:8px;padding:8px}
|
||||||
|
.big{font-size:20px;font-weight:600}.mut{color:var(--mut)}.ok{color:var(--ok)}.bad{color:var(--bad)}.dots i{display:inline-block;width:6px;height:6px;border-radius:50%;background:#26304d;margin:1px}.dots i.u{background:var(--ok)}
|
||||||
|
.dpad{display:grid;grid-template-columns:repeat(3,38px);gap:4px}.dpad button{height:38px;padding:0}
|
||||||
|
.shut{width:62px;height:62px;border-radius:50%;border:4px solid #fff;background:var(--bad);margin:4px auto 0;display:block}.shut.rec{border-radius:14px}
|
||||||
|
table{width:100%;border-collapse:collapse}td,th{padding:4px;text-align:left;font-weight:400}tr.rec{cursor:pointer}tr.rec:hover,tr.sel{background:#16203a}
|
||||||
|
#toast{position:fixed;bottom:14px;left:14px;background:#2a1018;border:1px solid var(--bad);padding:8px 12px;border-radius:6px;display:none;z-index:9}
|
||||||
|
@media(max-width:1000px){body{grid-template-columns:1fr;height:auto;overflow:auto}#view{height:auto;width:100%}}
|
||||||
|
</style></head><body>
|
||||||
|
<div id="view">
|
||||||
|
<img id="stream" alt="live view"><canvas id="ov"></canvas>
|
||||||
|
<div id="warn"></div>
|
||||||
|
<div class="hud" style="top:10px;left:10px"><b>Live View</b><div id="hudPoint" class="mut">no pointing</div><div id="hudDelta"></div></div>
|
||||||
|
<canvas id="pip" width="420" height="420"></canvas>
|
||||||
|
<div class="hud" style="bottom:52px;left:10px;width:230px"><div class="lh"><span>Field of view</span><b id="fovV"></b></div><input type="range" id="fov" min="0" max="100"></div>
|
||||||
|
<div class="hud" style="bottom:52px;right:10px" id="hudCam"></div>
|
||||||
|
<div class="hud" id="ovc"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col">
|
||||||
|
<section><h3>Target <small id="tName"></small></h3>
|
||||||
|
<div class="g2"><input id="tIn" type="text" placeholder="moon, vega, iss, sat:25544…"><button class="p" id="tSet">Set target</button></div>
|
||||||
|
<div id="tInfo" style="margin-top:8px"></div>
|
||||||
|
<div class="g2" style="margin-top:8px"><button id="zero">Zero on target</button><button id="unzero">Clear alignment</button></div><div id="align" class="mut" style="margin-top:6px"></div></section>
|
||||||
|
<section><h3>Mount & focuser <small>hardware not connected</small></h3>
|
||||||
|
<div style="display:flex;gap:18px;align-items:center;justify-content:center">
|
||||||
|
<div class="dpad"><span></span><button disabled>▲</button><span></span><button disabled>◀</button><button disabled>■</button><button disabled>▶</button><span></span><button disabled>▼</button><span></span></div>
|
||||||
|
<div style="display:flex;flex-direction:column;gap:4px;align-items:center"><span class="mut">Focus</span><button disabled style="width:60px">Out ▲</button><b id="fpos" class="mut">–</b><button disabled style="width:60px">In ▼</button></div></div>
|
||||||
|
<div class="ctl"><div class="lh">Slew speed</div><div class="segs"><button disabled>1</button><button disabled>2</button><button disabled class="on">3</button><button disabled>4</button><button disabled>5</button></div></div>
|
||||||
|
<div class="ctl"><div class="lh">Focus step</div><div class="segs"><button disabled>Fine</button><button disabled class="on">Medium</button><button disabled>Coarse</button></div></div></section>
|
||||||
|
<section><h3>Orientation <small id="oAl"></small></h3>
|
||||||
|
<div style="display:flex;gap:10px;align-items:center"><canvas id="att" width="200" height="200" style="width:170px;height:170px"></canvas><div id="oNums" style="flex:1"></div></div></section>
|
||||||
|
<section><h3>Telemetry</h3><div class="g2" id="tele"></div></section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col">
|
||||||
|
<section><h3>Capture <small id="camMode"></small></h3>
|
||||||
|
<div class="segs" id="capTabs"><button data-m="single" class="on">Photo</button><button data-m="timelapse">Timelapse</button><button data-m="video">Video</button></div>
|
||||||
|
<div id="capFields"></div><button class="shut" id="capGo" title="Capture"></button><div id="capJob" class="mut" style="margin-top:6px;text-align:center"></div></section>
|
||||||
|
<section><h3>Camera <button id="camReset" style="padding:1px 8px">Reset</button></h3><div id="cam"></div></section>
|
||||||
|
<section><h3>Recommended now</h3><table id="recs"></table></section>
|
||||||
|
</div>
|
||||||
|
<div id="toast"></div>
|
||||||
|
<script>
|
||||||
|
const $=(i)=>document.getElementById(i),rad=Math.PI/180,wrap180=(a)=>((a+540)%360)-180;
|
||||||
|
const ST=(k,d)=>{try{return JSON.parse(localStorage.getItem(k))??d}catch{return d}},SV=(k,v)=>{try{localStorage.setItem(k,JSON.stringify(v))}catch{}};
|
||||||
|
const S={pt:null,delta:null,objs:[],target:ST('target',''),cap:'single',cam:null,st:null,fovV:ST('fovV',40)},O=ST('ov',{labels:1,stars:1,dso:1,cross:1,err:1,map:1});
|
||||||
|
const j=async(u,o)=>{const r=await fetch(u,o&&{...o,headers:{'Content-Type':'application/json'},body:o.body&&JSON.stringify(o.body)});const d=await r.json().catch(()=>({}));if(!r.ok)throw new Error(d.message||d.error||r.status);return d};
|
||||||
|
const toast=(m)=>{const t=$('toast');t.textContent=m;t.style.display='block';clearTimeout(toast.t);toast.t=setTimeout(()=>t.style.display='none',4000)};
|
||||||
|
const act=(f)=>async(...a)=>{try{await f(...a)}catch(e){toast(e.message)}};
|
||||||
|
const tm=(s)=>s?new Date(s).toLocaleTimeString([],{hour:'numeric',minute:'2-digit'}):'—';
|
||||||
|
$('stream').src='/stream';
|
||||||
|
|
||||||
|
// ---------- camera controls (camera-app style) ----------
|
||||||
|
const SH=[0,250,500,1e3,2e3,4e3,8e3,16e3,33333,66666,125e3,25e4,5e5,1e6,2e6,5e6,1e7],IS=[0,100,200,400,800,1600,3200,6400],f2=(x)=>(+x).toFixed(2);
|
||||||
|
const fs=(u)=>u?(u>=1e6?u/1e6+' s':'1/'+Math.round(1e6/u)):'Auto';
|
||||||
|
const MAIN=[['shutterUs','Shutter','steps',SH,fs],['iso','ISO','steps',IS,(x)=>x||'Auto'],['ev','Exposure comp','range',-4,4,.33,(x)=>(x>0?'+':'')+(+x).toFixed(1)+' EV'],
|
||||||
|
['autofocusMode','Focus','seg',['manual','auto','continuous']],['lensPosition','Lens position','range',0,12,.1,(x)=>(+x).toFixed(1)+' D'],
|
||||||
|
['awb','White balance','seg',['auto','daylight','cloudy','tungsten','fluorescent']]];
|
||||||
|
const MORE=[['brightness','Brightness','range',-1,1,.05,f2],['contrast','Contrast','range',0,4,.05,f2],['saturation','Saturation','range',0,4,.05,f2],['sharpness','Sharpness','range',0,8,.25,f2],['quality','JPEG quality','range',1,100,1,(x)=>x],
|
||||||
|
['denoise','Denoise','seg',['auto','off','cdn_fast','cdn_hq']],['exposureMode','Exposure mode','seg',['normal','sport','long']],['rotation','Rotation','seg',[0,180]]];
|
||||||
|
const M={};
|
||||||
|
const ctl=(r)=>{const[k,l,t]=r;
|
||||||
|
if(t==='seg')return`<div class="ctl"><div class="lh">${l}</div><div class="segs" data-k="${k}">${r[3].map((o)=>`<button data-v="${o}">${o}</button>`).join('')}</div></div>`;
|
||||||
|
M[k]=t==='steps'?{vals:r[3],f:r[4],min:0,max:r[3].length-1,step:1}:{min:r[3],max:r[4],step:r[5],f:r[6]};const m=M[k];
|
||||||
|
return`<div class="ctl"><div class="lh"><span>${l}</span><b id="v_${k}"></b></div><input type="range" data-k="${k}" min="${m.min}" max="${m.max}" step="${m.step}"></div>`};
|
||||||
|
$('cam').innerHTML=MAIN.map(ctl).join('')+`<details><summary>Image & advanced</summary>${MORE.map(ctl).join('')}<div class="flips"><button class="chip" data-k="hflip">Mirror H</button><button class="chip" data-k="vflip">Mirror V</button></div></details>`;
|
||||||
|
function paint(){const s=S.cam;document.querySelectorAll('#cam [data-k]').forEach((el)=>{const k=el.dataset.k,v=s[k];
|
||||||
|
if(el.type==='range'){if(document.activeElement===el)return;const m=M[k];el.value=m.vals?m.vals.reduce((b,x,i)=>Math.abs(x-v)<Math.abs(m.vals[b]-v)?i:b,0):(v??0);$('v_'+k).textContent=m.f(m.vals?m.vals[el.value]:+el.value)}
|
||||||
|
else if(el.classList.contains('segs'))el.querySelectorAll('button').forEach((b)=>b.classList.toggle('on',b.dataset.v==String(v)));
|
||||||
|
else el.classList.toggle('on',!!v)});$('cam').classList.toggle('af',s.autofocusMode!=='manual')}
|
||||||
|
const tmr={};function send(k,v){S.cam[k]=v;clearTimeout(tmr[k]);tmr[k]=setTimeout(act(async()=>{S.cam=(await j('/api/camera/settings',{method:'PATCH',body:{[k]:v}})).settings;paint()}),250)}
|
||||||
|
$('cam').addEventListener('input',(e)=>{const el=e.target,k=el.dataset.k;if(el.type!=='range'||!k)return;const m=M[k],v=m.vals?m.vals[+el.value]:+el.value;$('v_'+k).textContent=m.f(v);send(k,v)});
|
||||||
|
$('cam').addEventListener('click',(e)=>{const b=e.target.closest('button');if(!b||!S.cam)return;const k=(b.closest('[data-k]')||b).dataset.k;
|
||||||
|
if(b.dataset.v!==undefined)send(k,k==='rotation'?Number(b.dataset.v):b.dataset.v);else send(k,!S.cam[k]);paint()});
|
||||||
|
$('camReset').onclick=act(async()=>{S.cam=(await j('/api/camera/settings/reset',{method:'POST',body:{}})).settings;paint()});
|
||||||
|
|
||||||
|
// ---------- capture ----------
|
||||||
|
const CAPF={single:`<div class="ctl lh"><span>Also save RAW (.dng)</span><input type="checkbox" id="raw"></div>`,
|
||||||
|
timelapse:`<div class="g2"><div class="ctl"><div class="lh">Interval (s)</div><input type="number" id="tlI" value="10"></div><div class="ctl"><div class="lh">Frames</div><input type="number" id="tlN" value="180"></div></div>`,
|
||||||
|
video:`<div class="g2"><div class="ctl"><div class="lh">Length (s, 0 = manual stop)</div><input type="number" id="vD" value="0"></div><div class="ctl"><div class="lh">FPS</div><input type="number" id="vF" value="30"></div></div>`};
|
||||||
|
const capUI=()=>{$('capFields').innerHTML=CAPF[S.cap];document.querySelectorAll('#capTabs button').forEach((b)=>b.classList.toggle('on',b.dataset.m===S.cap))};
|
||||||
|
$('capTabs').onclick=(e)=>{if(e.target.dataset.m){S.cap=e.target.dataset.m;capUI()}};capUI();
|
||||||
|
const nv=(i)=>Number($(i).value);
|
||||||
|
$('capGo').onclick=act(async()=>{const job=S.st?.camera?.job;
|
||||||
|
if(job?.type==='video')return j('/api/camera/video/stop',{method:'POST',body:{}});
|
||||||
|
if(job?.type==='timelapse')return j('/api/camera/timelapse/stop',{method:'POST',body:{}});
|
||||||
|
if(S.cap==='single'){const r=await j('/api/camera/photo',{method:'POST',body:{raw:$('raw').checked}});toast('Saved '+r.name)}
|
||||||
|
else if(S.cap==='timelapse')await j('/api/camera/timelapse/start',{method:'POST',body:{intervalSec:nv('tlI'),count:nv('tlN')}});
|
||||||
|
else await j('/api/camera/video/start',{method:'POST',body:{durationSec:nv('vD'),fps:nv('vF')}})});
|
||||||
|
|
||||||
|
// ---------- target ----------
|
||||||
|
$('tIn').value=S.target;
|
||||||
|
$('tSet').onclick=()=>setTarget($('tIn').value.trim());
|
||||||
|
function setTarget(t){S.target=t;SV('target',t);$('tIn').value=t;S.delta=null;refreshInfo();loadRecs();tick()}
|
||||||
|
$('zero').onclick=act(async()=>{const r=await j('/api/celestial/zero',{method:'POST',body:{target:S.target}});toast(`Aligned. Correction ${r.correction?.separation??'—'}°`)});
|
||||||
|
$('unzero').onclick=act(()=>j('/api/celestial/zero',{method:'DELETE'}));
|
||||||
|
async function refreshInfo(){if(!S.target){$('tInfo').innerHTML='<div class="mut">Pick a recommendation or type a target.</div>';return}
|
||||||
|
try{const i=await j('/api/sky/info?target='+encodeURIComponent(S.target)),e=i.events;$('tName').textContent=i.target.label;
|
||||||
|
const kv=(a,b)=>`<div class="mut">${a}</div><div>${b}</div>`;
|
||||||
|
$('tInfo').innerHTML=`<div class="g2">${kv('Alt / Az',i.alt+'° / '+i.az+'°')}${i.magnitude!=null?kv('Magnitude',i.magnitude)+kv('Distance',(i.distanceKm/1e6).toFixed(1)+'M km ('+i.distanceAu+' AU)')+kv('Illuminated',i.phasePct+'%')+kv('Elongation',i.elongationDeg+'°'):''}
|
||||||
|
${i.oppositionPct!=null?kv('Opposition',i.oppositionPct+'% · next '+new Date(i.nextOpposition).toLocaleDateString()):''}${e?kv('Rise / Transit / Set',tm(e.rise)+' / '+tm(e.transit)+' / '+tm(e.set)):''}${i.rangeKm?kv('Range',i.rangeKm+' km'):''}</div>${i.aboveHorizon?'':'<div class="bad">Below the horizon</div>'}`}
|
||||||
|
catch(e){$('tInfo').innerHTML=`<div class="bad">${e.message}</div>`}}
|
||||||
|
|
||||||
|
// ---------- status polling / render ----------
|
||||||
|
const ring=(p,c)=>`<svg viewBox="0 0 36 36" width="54"><circle cx="18" cy="18" r="15.9" fill="none" stroke="#1b2440" stroke-width="3.5"/><circle cx="18" cy="18" r="15.9" fill="none" stroke="${c}" stroke-width="3.5" pathLength="100" stroke-dasharray="${p} 100" stroke-linecap="round" transform="rotate(-90 18 18)"/><text x="18" y="21" text-anchor="middle" font-size="9" fill="#e6ebf7">${p}%</text></svg>`;
|
||||||
|
async function tick(){
|
||||||
|
try{S.st=await j('/api/status');const p=S.st.pointing;S.pt=p&&p.az!=null?p:null;render(S.st)}catch{$('hudPoint').textContent='server unreachable'}
|
||||||
|
if(S.target&&S.pt)try{S.delta=await j('/api/celestial/delta?target='+encodeURIComponent(S.target))}catch{S.delta=null}else S.delta=null;
|
||||||
|
draw();pip();att()}
|
||||||
|
function render(st){
|
||||||
|
const t=st.telemetry,c=st.camera,b=t.battery,g=t.gps,s=t.sensor,a=st.celestial.alignment,e=st.pointing?.error;
|
||||||
|
$('warn').style.display=S.pt?'none':'block';
|
||||||
|
$('warn').innerHTML=e==='no_position'?'<b>No observer position</b><br>Overlays and targets need a location. Wait for a GPS fix, or start the server with FALLBACK_LAT and FALLBACK_LON.':e==='no_orientation'?`<b>No IMU data</b><br>Sensor state: ${s.state}`:`<b>No pointing</b><br>${e||''}`;
|
||||||
|
$('hudPoint').innerHTML=S.pt?`Az ${S.pt.az}° Alt ${S.pt.alt}°${S.pt.aligned?'':' <span class="bad">(unaligned)</span>'}`:'<span class="bad">no pointing</span>';
|
||||||
|
const d=S.delta;$('hudDelta').innerHTML=d?`Δaz ${d.deltaAz}° ${d.hint.azimuth} · Δalt ${d.deltaAlt}° ${d.hint.altitude}<br>${d.onTarget?'<b class="ok">ON TARGET</b>':'separation '+d.separation+'°'}`:'';
|
||||||
|
const cs=c.settings;$('hudCam').textContent=`${fs(cs.shutterUs)} · ISO ${cs.iso||'Auto'} · ${c.stream.running?c.stream.width+'×'+c.stream.height+' @ '+c.stream.fps.toFixed(0)+' fps':'stream idle'}`;
|
||||||
|
if(!S.cam){S.cam=cs;paint()}
|
||||||
|
$('camMode').textContent=c.mode+(c.streamClients?` · ${c.streamClients} viewing`:'');
|
||||||
|
const jb=c.job;$('capJob').textContent=jb?(jb.type==='timelapse'?`Timelapse ${jb.frames}/${jb.total??'∞'}`:`Recording ${jb.elapsedSec}s`):(c.lastCapture?`Last: ${c.lastCapture.type}`:'');
|
||||||
|
$('capGo').classList.toggle('rec',!!jb);$('fpos').textContent=c.focus.position;
|
||||||
|
$('align').textContent=a.aligned?`Aligned on ${a.target?.label} at ${new Date(a.createdAt).toLocaleTimeString()}`:'Not aligned: centre a known object and press Zero.';
|
||||||
|
$('oAl').innerHTML=a.aligned?'<span class="ok">aligned</span>':'<span class="bad">unaligned</span>';
|
||||||
|
const o=t.orientation,n=(k,v,u='°')=>`<div class="lh"><span>${k}</span><b>${v}${u}</b></div>`;
|
||||||
|
$('oNums').innerHTML=(o?n('Yaw',o.yaw.toFixed(1))+n('Pitch',o.pitch.toFixed(1))+n('Roll',o.roll.toFixed(1)):'<div class="bad">no IMU data</div>')+(S.pt?n('Pointing az',S.pt.az)+n('Pointing alt',S.pt.alt)+n('Declination',S.pt.declination.deg):'');
|
||||||
|
const pct=b.available?b.percent:0,bc=pct<20?'#ff5c6c':pct<50?'#ffc247':'#3ddc84',fix=g.state.startsWith('fix');
|
||||||
|
const dots=g.satellites?Array.from({length:g.satellites.seen},(_,i)=>`<i class="${i<g.satellites.used?'u':''}"></i>`).join(''):'';
|
||||||
|
$('tele').innerHTML=`<div class="tile"><div class="mut">Battery</div><div style="display:flex;align-items:center;gap:8px">${ring(pct,bc)}<div>${b.available?b.voltageV+' V<br>'+(b.charging?'<span class="ok">charging ⚡</span>':'discharging'):'<span class="bad">unavailable</span>'}</div></div></div>
|
||||||
|
<div class="tile"><div class="mut">GPS</div><div class="big ${fix?'ok':'bad'}">${g.state.replace('_',' ')}</div><div class="dots">${dots}</div><div class="mut">${g.satellites?g.satellites.used+'/'+g.satellites.seen+' sats':''} ${g.dop?.hdop!=null?'· HDOP '+g.dop.hdop:''}</div></div>
|
||||||
|
<div class="tile"><div class="mut">Position</div>${g.position?`<div>${g.position.lat.toFixed(4)}, ${g.position.lon.toFixed(4)}</div><div class="mut">${g.position.altM.toFixed(0)} m${g.position.live?'':' · stale'}</div>`:'<div class="bad">none yet</div>'}</div>
|
||||||
|
<div class="tile"><div class="mut">IMU</div><div class="big ${s.state==='running'?'ok':'bad'}">${s.state}</div><div class="mut">${s.sampleAgeMs!=null?s.sampleAgeMs+' ms old':''} · ${s.restarts} restarts</div></div>
|
||||||
|
<div class="tile"><div class="mut">Clock</div><div class="big">${new Date(t.time.epochMs).toLocaleTimeString()}</div><div class="mut">${g.clockSkewMs!=null?'GPS skew '+g.clockSkewMs+' ms':'no GPS time'}</div></div>
|
||||||
|
<div class="tile"><div class="mut">Satellite TLEs</div><div class="big">${st.celestial.tle.satellites}</div><div class="${st.celestial.tle.lastError?'bad':'mut'}">${st.celestial.tle.lastError?'refresh failing':'cached'}</div></div>`}
|
||||||
|
|
||||||
|
// ---------- view overlays ----------
|
||||||
|
const fovDeg=()=>0.2*Math.pow(150,S.fovV/100);
|
||||||
|
$('fov').value=S.fovV;$('fov').oninput=()=>{S.fovV=+$('fov').value;SV('fovV',S.fovV);$('fovV').textContent=fovDeg().toFixed(1)+'°';draw()};$('fovV').textContent=fovDeg().toFixed(1)+'°';
|
||||||
|
const OVL=[['labels','Labels'],['stars','Stars'],['dso','Deep sky'],['cross','Crosshair'],['err','Error line'],['map','Sky map']];
|
||||||
|
$('ovc').innerHTML=OVL.map(([k,l])=>`<button class="chip ${O[k]?'on':''}" data-k="${k}">${l}</button>`).join('');
|
||||||
|
$('ovc').onclick=(e)=>{const k=e.target.dataset.k;if(!k)return;O[k]=O[k]?0:1;SV('ov',O);e.target.classList.toggle('on',!!O[k]);draw();pip()};
|
||||||
|
function draw(){
|
||||||
|
const c=$('ov'),g=c.getContext('2d'),W=c.width=c.clientWidth,H=c.height=c.clientHeight;g.clearRect(0,0,W,H);
|
||||||
|
if(!S.pt)return;const ppd=W/fovDeg(),cx=W/2,cy=H/2,P=S.pt,proj=(o)=>[cx+wrap180(o.az-P.az)*Math.cos(P.alt*rad)*ppd,cy-(o.alt-P.alt)*ppd];
|
||||||
|
g.font='12px system-ui';g.textBaseline='middle';
|
||||||
|
if(O.labels)for(const o of S.objs){const dso=/Galaxy|Nebula|Cluster|Globular/.test(o.type);if(o.type==='Star'&&!O.stars||dso&&!O.dso)continue;
|
||||||
|
const[x,y]=proj(o);if(x<-20||y<-20||x>W+20||y>H+20)continue;g.strokeStyle=g.fillStyle='#8fb4ff';g.beginPath();g.arc(x,y,9,0,7);g.stroke();g.fillText(o.label,x+14,y)}
|
||||||
|
if(O.cross){g.strokeStyle='#ff5c6c99';g.beginPath();g.moveTo(cx-22,cy);g.lineTo(cx+22,cy);g.moveTo(cx,cy-22);g.lineTo(cx,cy+22);g.stroke()}
|
||||||
|
const d=S.delta;if(d&&O.err){let[x,y]=proj(d.target);const off=x<0||y<0||x>W||y>H;g.strokeStyle=g.fillStyle=d.onTarget?'#3ddc84':'#ffc247';g.setLineDash([6,5]);g.beginPath();g.moveTo(cx,cy);
|
||||||
|
if(off){const k=.45*W/Math.hypot(x-cx,y-cy);x=cx+(x-cx)*k;y=cy+(y-cy)*k}g.lineTo(x,y);g.stroke();g.setLineDash([]);g.beginPath();g.arc(x,y,12,0,7);g.stroke();g.fillText(`${d.target.target.label} ${d.separation}°${off?' (off-screen)':''}`,x+16,y)}}
|
||||||
|
function pip(){
|
||||||
|
const c=$('pip');c.style.display=O.map?'block':'none';const g=c.getContext('2d'),R=185,cx=210,cy=210;g.clearRect(0,0,420,420);
|
||||||
|
g.strokeStyle='#1b2440';g.fillStyle='#7f8aa8';g.font='20px system-ui';g.textAlign='center';[R,R*2/3,R/3].forEach((r)=>{g.beginPath();g.arc(cx,cy,r,0,7);g.stroke()});
|
||||||
|
[['N',0],['E',90],['S',180],['W',270]].forEach(([l,a])=>g.fillText(l,cx-(R+14)*Math.sin(a*rad),cy-(R+14)*Math.cos(a*rad)+6));
|
||||||
|
const xy=(az,alt)=>{const r=(90-Math.max(alt,0))/90*R;return[cx-r*Math.sin(az*rad),cy-r*Math.cos(az*rad)]};g.font='16px system-ui';
|
||||||
|
for(const o of S.objs){if(o.alt<0)continue;const[x,y]=xy(o.az,o.alt),t=o.spec===S.target;g.fillStyle=t?'#ffc247':'#cfd8f5';g.beginPath();g.arc(x,y,t?7:/Planet|Moon/.test(o.type)?5:3,0,7);g.fill();if(t||/Planet|Moon/.test(o.type))g.fillText(o.label.split(' ')[0],x+8,y-8)}
|
||||||
|
if(S.pt){const[x,y]=xy(S.pt.az,S.pt.alt);g.strokeStyle='#ff5c6c';g.lineWidth=2;g.beginPath();g.arc(x,y,10,0,7);g.moveTo(x-16,y);g.lineTo(x+16,y);g.moveTo(x,y-16);g.lineTo(x,y+16);g.stroke();g.lineWidth=1}}
|
||||||
|
|
||||||
|
// ---------- attitude + compass ----------
|
||||||
|
function att(){
|
||||||
|
const c=$('att'),g=c.getContext('2d'),R=94,cx=100,cy=100,o=S.st?.telemetry.orientation;g.clearRect(0,0,200,200);
|
||||||
|
if(!o){g.fillStyle='#7f8aa8';g.textAlign='center';g.fillText('no IMU data',cx,cy);return}
|
||||||
|
g.save();g.beginPath();g.arc(cx,cy,R-16,0,7);g.clip();g.translate(cx,cy);g.rotate(-o.roll*rad);g.translate(0,o.pitch*1.6);
|
||||||
|
g.fillStyle='#1b4b8f';g.fillRect(-200,-400,400,400);g.fillStyle='#6b4a2a';g.fillRect(-200,0,400,400);g.strokeStyle='#fff';g.beginPath();g.moveTo(-200,0);g.lineTo(200,0);g.stroke();g.restore();
|
||||||
|
g.save();g.translate(cx,cy);g.rotate(-o.yaw*rad);g.strokeStyle='#7f8aa8';g.textAlign='center';g.textBaseline='middle';g.font='13px system-ui';
|
||||||
|
for(let a=0;a<360;a+=10){g.save();g.rotate(a*rad);g.beginPath();g.moveTo(0,-R);g.lineTo(0,-R+(a%30?4:9));g.stroke();g.restore()}
|
||||||
|
[['N',0],['E',90],['S',180],['W',270]].forEach(([l,a])=>{g.save();g.rotate(a*rad);g.translate(0,-R+17);g.fillStyle=l==='N'?'#ff5c6c':'#e6ebf7';g.fillText(l,0,0);g.restore()});g.restore();
|
||||||
|
g.fillStyle=g.strokeStyle='#ffc247';g.beginPath();g.moveTo(cx,cy-R+1);g.lineTo(cx-5,cy-R-8);g.lineTo(cx+5,cy-R-8);g.fill();
|
||||||
|
g.lineWidth=2;g.beginPath();g.moveTo(cx-32,cy);g.lineTo(cx-10,cy);g.moveTo(cx+10,cy);g.lineTo(cx+32,cy);g.stroke()}
|
||||||
|
|
||||||
|
// ---------- recommendations / objects ----------
|
||||||
|
async function loadRecs(){try{const r=await j('/api/sky/recommend?limit=8');
|
||||||
|
$('recs').innerHTML=r.recommendations.length?'<tr class="mut"><th>Target</th><th>Alt</th><th>Mag</th><th>Why</th></tr>'+r.recommendations.map((x)=>`<tr class="rec ${x.spec===S.target?'sel':''}" data-spec="${x.spec}"><td>${x.label}<div class="mut">${x.type}</div></td><td>${x.alt}°</td><td>${x.mag??'–'}</td><td class="mut">${x.why.join(', ')}</td></tr>`).join(''):'<tr><td class="mut">Nothing above the horizon right now.</td></tr>'}
|
||||||
|
catch(e){$('recs').innerHTML=`<tr><td class="bad">${e.message}</td></tr>`}}
|
||||||
|
$('recs').onclick=(e)=>{const tr=e.target.closest('tr.rec');if(tr)setTarget(tr.dataset.spec)};
|
||||||
|
const loadObjs=async()=>{try{S.objs=(await j('/api/sky/objects')).objects}catch{S.objs=[]}};
|
||||||
|
capUI();loadObjs();loadRecs();refreshInfo();tick();
|
||||||
|
setInterval(tick,1000);setInterval(loadObjs,5000);setInterval(loadRecs,60000);setInterval(refreshInfo,15000);
|
||||||
|
</script></body></html>
|
||||||
+157
-28
@@ -1,15 +1,20 @@
|
|||||||
import { createServer } from 'node:http';
|
import { createServer } from 'node:http';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import fsp from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
import { TelemetryService } from './services/telemetry.mjs';
|
import { TelemetryService } from './services/telemetry.mjs';
|
||||||
import { CameraService } from './services/camera.mjs';
|
import { CameraService } from './services/camera.mjs';
|
||||||
import { CelestialService } from './services/celestial.mjs';
|
import { CelestialService } from './services/celestial.mjs';
|
||||||
|
import { SkyService } from './services/sky.mjs';
|
||||||
|
|
||||||
const ROOT = path.dirname(fileURLToPath(import.meta.url));
|
const ROOT = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const DATA_DIR = path.resolve(process.env.DATA_DIR || path.join(ROOT, 'data'));
|
const DATA_DIR = path.resolve(process.env.DATA_DIR || path.join(ROOT, 'data'));
|
||||||
const PORT = Number(process.env.PORT || 8080);
|
const PORT = Number(process.env.PORT || 8080);
|
||||||
const HOST = process.env.HOST || '0.0.0.0';
|
const HOST = process.env.HOST || '0.0.0.0';
|
||||||
|
// UI lives in the project root. Override with UI_FILE if you keep it elsewhere (e.g. ui/index.html).
|
||||||
|
const UI_FILE = path.resolve(process.env.UI_FILE || path.join(ROOT, 'index.html'));
|
||||||
|
|
||||||
const log = Object.fromEntries(['debug', 'info', 'warn', 'error'].map((level) => [
|
const log = Object.fromEntries(['debug', 'info', 'warn', 'error'].map((level) => [
|
||||||
level,
|
level,
|
||||||
@@ -43,9 +48,9 @@ const cameraConfig = {
|
|||||||
vidBin: process.env.RPICAM_VID || 'rpicam-vid',
|
vidBin: process.env.RPICAM_VID || 'rpicam-vid',
|
||||||
stillBin: process.env.RPICAM_STILL || 'rpicam-still',
|
stillBin: process.env.RPICAM_STILL || 'rpicam-still',
|
||||||
ffmpegBin: process.env.FFMPEG || 'ffmpeg',
|
ffmpegBin: process.env.FFMPEG || 'ffmpeg',
|
||||||
width: Number(process.env.W || 1280),
|
width: Number(process.env.W || 720),
|
||||||
height: Number(process.env.H || 720),
|
height: Number(process.env.H || 720),
|
||||||
fps: Number(process.env.FPS || 30),
|
fps: Number(process.env.FPS || 60),
|
||||||
streamQuality: 85,
|
streamQuality: 85,
|
||||||
streamMaxShutterUs: 1_000_000,
|
streamMaxShutterUs: 1_000_000,
|
||||||
streamIdleStopMs: 5000,
|
streamIdleStopMs: 5000,
|
||||||
@@ -81,6 +86,7 @@ if (process.env.DECLINATION_DEG !== undefined) {
|
|||||||
|
|
||||||
const telemetry = new TelemetryService(telemetryConfig, { log });
|
const telemetry = new TelemetryService(telemetryConfig, { log });
|
||||||
const celestial = new CelestialService(celestialConfig, { telemetry, log, dataDir: DATA_DIR });
|
const celestial = new CelestialService(celestialConfig, { telemetry, log, dataDir: DATA_DIR });
|
||||||
|
const sky = new SkyService({ celestial, telemetry });
|
||||||
const camera = new CameraService(cameraConfig, {
|
const camera = new CameraService(cameraConfig, {
|
||||||
log,
|
log,
|
||||||
dataDir: DATA_DIR,
|
dataDir: DATA_DIR,
|
||||||
@@ -106,6 +112,38 @@ function sendJson(res, status, data) {
|
|||||||
res.end(JSON.stringify(data));
|
res.end(JSON.stringify(data));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MIME = {
|
||||||
|
'.html': 'text/html; charset=utf-8',
|
||||||
|
'.js': 'text/javascript; charset=utf-8',
|
||||||
|
'.css': 'text/css; charset=utf-8',
|
||||||
|
'.json': 'application/json; charset=utf-8',
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.jpeg': 'image/jpeg',
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.mp4': 'video/mp4',
|
||||||
|
'.h264': 'video/h264',
|
||||||
|
'.dng': 'image/x-adobe-dng',
|
||||||
|
};
|
||||||
|
|
||||||
|
async function serveFile(res, file, cache = 'no-cache') {
|
||||||
|
let st;
|
||||||
|
try {
|
||||||
|
st = await fsp.stat(file);
|
||||||
|
if (!st.isFile()) throw new Error('not a file');
|
||||||
|
} catch {
|
||||||
|
return sendJson(res, 404, { error: 'not_found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': MIME[path.extname(file).toLowerCase()] || 'application/octet-stream',
|
||||||
|
'Content-Length': st.size,
|
||||||
|
'Cache-Control': cache,
|
||||||
|
});
|
||||||
|
fs.createReadStream(file)
|
||||||
|
.on('error', (err) => res.destroy(err))
|
||||||
|
.pipe(res);
|
||||||
|
}
|
||||||
|
|
||||||
function readJson(req) {
|
function readJson(req) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let body = '';
|
let body = '';
|
||||||
@@ -134,8 +172,35 @@ function readJson(req) {
|
|||||||
|
|
||||||
async function handle(req, res) {
|
async function handle(req, res) {
|
||||||
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
|
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
|
||||||
|
const is = (method, pathname) => req.method === method && url.pathname === pathname;
|
||||||
|
|
||||||
if (req.method === 'GET' && url.pathname === '/') {
|
// ───────────── UI + static ─────────────
|
||||||
|
|
||||||
|
if (is('GET', '/')) {
|
||||||
|
res.writeHead(302, { Location: '/index.html', 'Cache-Control': 'no-store' });
|
||||||
|
return res.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is('GET', '/index.html')) {
|
||||||
|
return serveFile(res, UI_FILE);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === 'GET' && url.pathname.startsWith('/media/')) {
|
||||||
|
let rel;
|
||||||
|
try {
|
||||||
|
rel = decodeURIComponent(url.pathname.slice('/media/'.length));
|
||||||
|
} catch {
|
||||||
|
return sendJson(res, 400, { error: 'bad_path' });
|
||||||
|
}
|
||||||
|
const base = path.resolve(camera.mediaRoot);
|
||||||
|
const file = path.resolve(base, rel);
|
||||||
|
if (!file.startsWith(base + path.sep)) return sendJson(res, 403, { error: 'forbidden' });
|
||||||
|
return serveFile(res, file, 'public, max-age=3600');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────── API index ─────────────
|
||||||
|
|
||||||
|
if (is('GET', '/api')) {
|
||||||
return sendJson(res, 200, {
|
return sendJson(res, 200, {
|
||||||
name: 'MRS',
|
name: 'MRS',
|
||||||
status: 'online',
|
status: 'online',
|
||||||
@@ -144,34 +209,29 @@ async function handle(req, res) {
|
|||||||
'/api/status',
|
'/api/status',
|
||||||
'/api/telemetry/battery',
|
'/api/telemetry/battery',
|
||||||
'/api/camera/settings',
|
'/api/camera/settings',
|
||||||
|
'/api/camera/files',
|
||||||
|
'/api/celestial/predict?target=',
|
||||||
|
'/api/celestial/delta?target=',
|
||||||
|
'/api/celestial/targets',
|
||||||
|
'/api/celestial/alignment',
|
||||||
|
'/api/sky/info?target=',
|
||||||
|
'/api/sky/recommend',
|
||||||
|
'/api/sky/objects',
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === 'GET' && url.pathname === '/stream') {
|
// ───────────── stream / telemetry ─────────────
|
||||||
|
|
||||||
|
if (is('GET', '/stream')) {
|
||||||
return camera.addStreamClient(req, res);
|
return camera.addStreamClient(req, res);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === 'GET' && url.pathname === '/api/telemetry/battery') {
|
if (is('GET', '/api/telemetry/battery')) {
|
||||||
return sendJson(res, 200, { battery: telemetry.getBattery() });
|
return sendJson(res, 200, { battery: telemetry.getBattery() });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === 'GET' && url.pathname === '/api/camera/settings') {
|
if (is('GET', '/api/status')) {
|
||||||
return sendJson(res, 200, { settings: camera.getSettings() });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.method === 'PATCH' && url.pathname === '/api/camera/settings') {
|
|
||||||
const body = await readJson(req);
|
|
||||||
const settings = await camera.applySettings(body);
|
|
||||||
return sendJson(res, 200, { ok: true, settings });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.method === 'POST' && url.pathname === '/api/camera/settings/reset') {
|
|
||||||
const settings = await camera.resetSettings();
|
|
||||||
return sendJson(res, 200, { ok: true, settings });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.method === 'GET' && url.pathname === '/api/status') {
|
|
||||||
let celestialStatus;
|
let celestialStatus;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -189,7 +249,53 @@ async function handle(req, res) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === 'GET' && url.pathname === '/api/celestial/predict') {
|
// ───────────── camera settings ─────────────
|
||||||
|
|
||||||
|
if (is('GET', '/api/camera/settings')) {
|
||||||
|
return sendJson(res, 200, { settings: camera.getSettings() });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is('PATCH', '/api/camera/settings')) {
|
||||||
|
const body = await readJson(req);
|
||||||
|
const settings = await camera.applySettings(body);
|
||||||
|
return sendJson(res, 200, { ok: true, settings });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is('POST', '/api/camera/settings/reset')) {
|
||||||
|
const settings = await camera.resetSettings();
|
||||||
|
return sendJson(res, 200, { ok: true, settings });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────── camera capture jobs ─────────────
|
||||||
|
|
||||||
|
if (is('POST', '/api/camera/photo')) {
|
||||||
|
return sendJson(res, 200, await camera.takePhoto(await readJson(req)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is('POST', '/api/camera/video/start')) {
|
||||||
|
return sendJson(res, 200, { ok: true, job: await camera.startVideo(await readJson(req)) });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is('POST', '/api/camera/video/stop')) {
|
||||||
|
return sendJson(res, 200, await camera.stopVideo());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is('POST', '/api/camera/timelapse/start')) {
|
||||||
|
return sendJson(res, 200, { ok: true, job: await camera.startTimelapse(await readJson(req)) });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is('POST', '/api/camera/timelapse/stop')) {
|
||||||
|
return sendJson(res, 200, await camera.stopTimelapse());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is('GET', '/api/camera/files')) {
|
||||||
|
const limit = Math.min(500, Math.max(1, Number(url.searchParams.get('limit') || 50)));
|
||||||
|
return sendJson(res, 200, await camera.listFiles({ limit }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────── celestial ─────────────
|
||||||
|
|
||||||
|
if (is('GET', '/api/celestial/predict')) {
|
||||||
const target = url.searchParams.get('target');
|
const target = url.searchParams.get('target');
|
||||||
if (!target) {
|
if (!target) {
|
||||||
return sendJson(res, 400, {
|
return sendJson(res, 400, {
|
||||||
@@ -200,7 +306,7 @@ async function handle(req, res) {
|
|||||||
return sendJson(res, 200, celestial.predict(target));
|
return sendJson(res, 200, celestial.predict(target));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === 'GET' && url.pathname === '/api/celestial/delta') {
|
if (is('GET', '/api/celestial/delta')) {
|
||||||
const target = url.searchParams.get('target');
|
const target = url.searchParams.get('target');
|
||||||
if (!target) {
|
if (!target) {
|
||||||
return sendJson(res, 400, {
|
return sendJson(res, 400, {
|
||||||
@@ -211,18 +317,18 @@ async function handle(req, res) {
|
|||||||
return sendJson(res, 200, celestial.delta(target));
|
return sendJson(res, 200, celestial.delta(target));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === 'GET' && url.pathname === '/api/celestial/targets') {
|
if (is('GET', '/api/celestial/targets')) {
|
||||||
const kind = url.searchParams.get('kind') || undefined;
|
const kind = url.searchParams.get('kind') || undefined;
|
||||||
const q = url.searchParams.get('q') || undefined;
|
const q = url.searchParams.get('q') || undefined;
|
||||||
const limit = Math.min(500, Math.max(1, Number(url.searchParams.get('limit') || 100)));
|
const limit = Math.min(500, Math.max(1, Number(url.searchParams.get('limit') || 100)));
|
||||||
return sendJson(res, 200, { targets: celestial.listTargets({ kind, q, limit }) });
|
return sendJson(res, 200, { targets: celestial.listTargets({ kind, q, limit }) });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === 'GET' && url.pathname === '/api/celestial/alignment') {
|
if (is('GET', '/api/celestial/alignment')) {
|
||||||
return sendJson(res, 200, celestial.getAlignment());
|
return sendJson(res, 200, celestial.getAlignment());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === 'POST' && url.pathname === '/api/celestial/zero') {
|
if (is('POST', '/api/celestial/zero')) {
|
||||||
const body = await readJson(req);
|
const body = await readJson(req);
|
||||||
if (!body.target) {
|
if (!body.target) {
|
||||||
return sendJson(res, 400, {
|
return sendJson(res, 400, {
|
||||||
@@ -233,15 +339,38 @@ async function handle(req, res) {
|
|||||||
return sendJson(res, 200, await celestial.zero(body.target));
|
return sendJson(res, 200, await celestial.zero(body.target));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === 'DELETE' && url.pathname === '/api/celestial/zero') {
|
if (is('DELETE', '/api/celestial/zero')) {
|
||||||
return sendJson(res, 200, await celestial.clearZero());
|
return sendJson(res, 200, await celestial.clearZero());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === 'POST' && url.pathname === '/api/celestial/tle/refresh') {
|
if (is('POST', '/api/celestial/tle/refresh')) {
|
||||||
const body = await readJson(req);
|
const body = await readJson(req);
|
||||||
return sendJson(res, 200, { results: await celestial.refreshTLEs({ force: body.force !== false }) });
|
return sendJson(res, 200, { results: await celestial.refreshTLEs({ force: body.force !== false }) });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ───────────── sky (info / recommendations / overlay) ─────────────
|
||||||
|
|
||||||
|
if (is('GET', '/api/sky/info')) {
|
||||||
|
const target = url.searchParams.get('target');
|
||||||
|
if (!target) {
|
||||||
|
return sendJson(res, 400, {
|
||||||
|
error: 'missing_target',
|
||||||
|
message: 'Pass ?target=moon, saturn, vega, iss, radec:5.9,7.4, etc.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return sendJson(res, 200, sky.info(target));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is('GET', '/api/sky/recommend')) {
|
||||||
|
const limit = Math.min(50, Math.max(1, Number(url.searchParams.get('limit') || 8)));
|
||||||
|
const minAlt = Number(url.searchParams.get('minAlt') || 15);
|
||||||
|
return sendJson(res, 200, { recommendations: sky.recommend({ limit, minAlt }) });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is('GET', '/api/sky/objects')) {
|
||||||
|
return sendJson(res, 200, sky.objects());
|
||||||
|
}
|
||||||
|
|
||||||
return sendJson(res, 404, { error: 'not_found', path: url.pathname });
|
return sendJson(res, 404, { error: 'not_found', path: url.pathname });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
// Sky service: target info, "what should I look at" recommendations, and overlay objects.
|
||||||
|
// Positions come from CelestialService.predict() (astronomy-engine, fully offline for Sun/Moon/planets/stars).
|
||||||
|
import * as Astronomy from 'astronomy-engine';
|
||||||
|
import { STARS } from '../lib/stars.mjs';
|
||||||
|
import { angularSeparation } from '../lib/geo.mjs';
|
||||||
|
|
||||||
|
// Bundled deep-sky showpieces (J2000 RA hours, Dec deg, visual magnitude). Swap for OpenNGC later.
|
||||||
|
const DSO = [
|
||||||
|
['m31', 'M31 Andromeda Galaxy', 'Galaxy', 0.712, 41.27, 3.4],
|
||||||
|
['m42', 'M42 Orion Nebula', 'Nebula', 5.588, -5.39, 4.0],
|
||||||
|
['m45', 'M45 Pleiades', 'Open Cluster', 3.79, 24.12, 1.6],
|
||||||
|
['m44', 'M44 Beehive', 'Open Cluster', 8.667, 19.67, 3.7],
|
||||||
|
['m13', 'M13 Hercules Cluster', 'Globular', 16.695, 36.46, 5.8],
|
||||||
|
['m8', 'M8 Lagoon Nebula', 'Nebula', 18.063, -24.38, 6.0],
|
||||||
|
['m27', 'M27 Dumbbell Nebula', 'Planetary Nebula', 19.994, 22.72, 7.4],
|
||||||
|
['m57', 'M57 Ring Nebula', 'Planetary Nebula', 18.893, 33.03, 8.8],
|
||||||
|
['m81', 'M81 Bode\'s Galaxy', 'Galaxy', 9.926, 69.07, 6.9],
|
||||||
|
['m51', 'M51 Whirlpool Galaxy', 'Galaxy', 13.498, 47.2, 8.4],
|
||||||
|
].map(([id, label, type, ra, dec, mag]) => ({ id, label, type, ra, dec, mag, spec: `radec:${ra},${dec}` }));
|
||||||
|
|
||||||
|
const PLANETS = ['mercury', 'venus', 'mars', 'jupiter', 'saturn', 'uranus', 'neptune'];
|
||||||
|
const OUTER = ['Mars', 'Jupiter', 'Saturn', 'Uranus', 'Neptune'];
|
||||||
|
const r = (v, d = 1) => (v == null ? v : Math.round(v * 10 ** d) / 10 ** d);
|
||||||
|
const iso = (t) => t?.date?.toISOString?.() ?? null;
|
||||||
|
const cap = (s) => s[0].toUpperCase() + s.slice(1);
|
||||||
|
|
||||||
|
export class SkyService {
|
||||||
|
#c;
|
||||||
|
#t;
|
||||||
|
|
||||||
|
constructor({ celestial, telemetry }) {
|
||||||
|
this.#c = celestial;
|
||||||
|
this.#t = telemetry;
|
||||||
|
}
|
||||||
|
|
||||||
|
#candidates() {
|
||||||
|
return [
|
||||||
|
{ id: 'body:moon', label: 'Moon', type: 'Moon', spec: 'moon' },
|
||||||
|
...PLANETS.map((p) => ({ id: `body:${p}`, label: cap(p), type: 'Planet', spec: `body:${p}` })),
|
||||||
|
...Object.entries(STARS).map(([k, s]) => ({ id: `star:${k}`, label: s.label, type: 'Star', mag: s.mag ?? 2, spec: `star:${k}` })),
|
||||||
|
...DSO.map((d) => ({ ...d, id: `dso:${d.id}` })),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Detail for one target: position + (for solar-system bodies) phase, magnitude, elongation, opposition, rise/transit/set. */
|
||||||
|
info(spec) {
|
||||||
|
const date = this.#t.now();
|
||||||
|
const p = this.#c.predict(spec, { date });
|
||||||
|
const out = { ...p };
|
||||||
|
if (p.target.kind !== 'body') return out;
|
||||||
|
|
||||||
|
const name = p.target.label;
|
||||||
|
const o = new Astronomy.Observer(p.observer.lat, p.observer.lon, p.observer.altM);
|
||||||
|
const ill = Astronomy.Illumination(name, date);
|
||||||
|
out.magnitude = r(ill.mag);
|
||||||
|
out.phasePct = r(ill.phase_fraction * 100, 0);
|
||||||
|
out.distanceKm = Math.round(p.distanceAu * 149_597_870.7);
|
||||||
|
out.elongationDeg = name === 'Sun' ? 0 : r(Astronomy.AngleFromSun(name, date));
|
||||||
|
out.events = {
|
||||||
|
rise: iso(Astronomy.SearchRiseSet(name, o, +1, date, 2)),
|
||||||
|
transit: iso(Astronomy.SearchHourAngle(name, o, 0, date).time),
|
||||||
|
set: iso(Astronomy.SearchRiseSet(name, o, -1, date, 2)),
|
||||||
|
};
|
||||||
|
if (OUTER.includes(name)) {
|
||||||
|
out.oppositionPct = r((out.elongationDeg / 180) * 100, 0); // 100% = exactly at opposition
|
||||||
|
out.nextOpposition = iso(Astronomy.SearchRelativeLongitude(name, 180, date));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ranked list of what's worth observing right now. */
|
||||||
|
recommend({ limit = 8, minAlt = 15 } = {}) {
|
||||||
|
const date = this.#t.now();
|
||||||
|
const moon = this.#c.predict('moon', { date });
|
||||||
|
const moonIllum = (1 - Math.cos((Astronomy.MoonPhase(date) * Math.PI) / 180)) / 2;
|
||||||
|
const rows = [];
|
||||||
|
|
||||||
|
for (const c of this.#candidates()) {
|
||||||
|
let p;
|
||||||
|
try {
|
||||||
|
p = this.#c.predict(c.spec, { date });
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (p.alt < minAlt) continue;
|
||||||
|
|
||||||
|
const row = { id: c.id, spec: c.spec, label: c.label, type: c.type, az: p.az, alt: p.alt, why: [] };
|
||||||
|
let mag = c.mag;
|
||||||
|
let score = (Math.min(p.alt, 70) / 70) * 40;
|
||||||
|
|
||||||
|
if (c.type === 'Planet' || c.type === 'Moon') {
|
||||||
|
const ill = Astronomy.Illumination(c.label, date);
|
||||||
|
mag = ill.mag;
|
||||||
|
row.phasePct = r(ill.phase_fraction * 100, 0);
|
||||||
|
const el = Astronomy.AngleFromSun(c.label, date);
|
||||||
|
if (OUTER.includes(c.label)) {
|
||||||
|
row.oppositionPct = r((el / 180) * 100, 0);
|
||||||
|
if (el > 150) { score += 15; row.why.push('near opposition'); }
|
||||||
|
}
|
||||||
|
if (el < 20 && c.type === 'Planet') { score -= 30; row.why.push('close to the Sun'); }
|
||||||
|
if (c.type === 'Planet') score += 10;
|
||||||
|
}
|
||||||
|
const moonSep = angularSeparation(p.az, p.alt, moon.az, moon.alt);
|
||||||
|
if (c.type !== 'Moon' && mag > 4 && moonSep < 40) {
|
||||||
|
score -= (40 - moonSep) * 0.6 * moonIllum;
|
||||||
|
row.why.push('washed out by Moon');
|
||||||
|
}
|
||||||
|
score += Math.max(0, 10 - (mag ?? 10)) * 3;
|
||||||
|
if (p.alt > 50) row.why.push('high in sky');
|
||||||
|
|
||||||
|
row.mag = r(mag);
|
||||||
|
row.moonSepDeg = r(moonSep, 0);
|
||||||
|
row.score = r(score, 0);
|
||||||
|
rows.push(row);
|
||||||
|
}
|
||||||
|
return rows.sort((a, b) => b.score - a.score).slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything above (roughly) the horizon, for the sky-map / live overlay. */
|
||||||
|
objects() {
|
||||||
|
const date = this.#t.now();
|
||||||
|
const out = [];
|
||||||
|
for (const c of this.#candidates()) {
|
||||||
|
try {
|
||||||
|
const p = this.#c.predict(c.spec, { date });
|
||||||
|
if (p.alt > -5) out.push({ id: c.id, spec: c.spec, label: c.label, type: c.type, mag: c.mag ?? null, az: p.az, alt: p.alt });
|
||||||
|
} catch { /* skip */ }
|
||||||
|
}
|
||||||
|
return { time: date.toISOString(), objects: out };
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user