easier instructions and connection for multi-computer over lan
This commit is contained in:
parent
d3f7f374ab
commit
cbd4b25bc6
112
README.md
112
README.md
@ -1,6 +1,63 @@
|
||||
# Bilateral Stimulation Simulator
|
||||
|
||||
Simple browser-based tool with an Admin panel and a full-screen Patient view. The Admin controls everything; the Patient view mirrors motion and audio.
|
||||
Simple browser-based tool with an therapist panel and a full-screen Patient view. The therapist controls everything; the Patient view mirrors motion and audio.
|
||||
|
||||
## How It Works
|
||||
|
||||
- Two pages:
|
||||
- Therapist (Admin) page: where you set motion, colors, and sounds
|
||||
- Patient page: fullscreen view that mirrors the Admin
|
||||
- Two ways to show the Patient page:
|
||||
- Same computer (extended monitor): plug a second display facing the patient, open the Patient page on that display, go fullscreen — no network setup needed
|
||||
- Two computers on the same Wi‑Fi/LAN: start a tiny local server on the therapist’s computer so both can communicate, then the patient joins from their computer
|
||||
- Audio note: most browsers need you to click on the page once to activate sound
|
||||
- Go gently — adjust speed, colors, and sounds to what feels comfortable; pause whenever needed
|
||||
|
||||
## Super Simple Guide
|
||||
|
||||
### Windows
|
||||
- First‑Time Setup
|
||||
- Double‑click `setup.bat`
|
||||
- Allow the Windows Defender Firewall prompt (Private networks)
|
||||
- Your browser opens at `http://<your‑IP>:8000/`; “Relay” turns green
|
||||
- Every Session
|
||||
- Double‑click `start-relay.bat`
|
||||
- Click “Detect Local IP” in the app; the Patient link auto‑copies
|
||||
- Stop Server
|
||||
- Use “Stop Server” in the app, or press `Ctrl + C` in the relay window
|
||||
|
||||
### macOS
|
||||
- First‑Time Setup
|
||||
- Press `Cmd + Space` → type `Terminal` → press `Enter`
|
||||
- `cd` into the folder where you unzipped the app
|
||||
- Copy and paste: `chmod +x ./setup.sh && ./setup.sh`
|
||||
- Allow the macOS firewall prompt (Private networks)
|
||||
- Your browser opens at `http://<your‑IP>:8000/`; “Relay” turns green
|
||||
- Every Session
|
||||
- Press `Cmd + Space` → type `Terminal` → `Enter`
|
||||
- `cd` into the app folder
|
||||
- Copy and paste: `chmod +x ./start-relay.sh && ./start-relay.sh`
|
||||
- Click “Detect Local IP”; the Patient link auto‑copies
|
||||
- Stop Server
|
||||
- Use “Stop Server” in the app, or press `Ctrl + C` in the relay Terminal
|
||||
|
||||
### Linux
|
||||
- First‑Time Setup
|
||||
- Press `Ctrl + Alt + T` to open Terminal
|
||||
- `cd` into the app folder
|
||||
- Copy and paste: `chmod +x ./setup.sh && ./setup.sh`
|
||||
- Your browser opens at `http://<your‑IP>:8000/`; “Relay” turns green
|
||||
- Every Session
|
||||
- Open Terminal → `cd` into the app folder
|
||||
- Copy and paste: `chmod +x ./start-relay.sh && ./start-relay.sh`
|
||||
- Click “Detect Local IP”; the Patient link auto‑copies
|
||||
- Stop Server
|
||||
- Use “Stop Server” in the app, or press `Ctrl + C` in the relay Terminal
|
||||
|
||||
### What You Should See
|
||||
- “Relay:” shows a green dot and “Running”
|
||||
- “Detect Local IP” fills `ws://<your‑IP>:8787` and copies the Patient link
|
||||
- Admin page loads at `http://<your‑IP>:8000/`
|
||||
|
||||
## Admin Options
|
||||
|
||||
@ -94,23 +151,48 @@ Simple browser-based tool with an Admin panel and a full-screen Patient view. Th
|
||||
- Admin connects to the relay with a Session ID and copies the Patient Join Link.
|
||||
- Patient opens the link on their computer and performs a quick gesture to unlock audio.
|
||||
|
||||
### Quick Start (Double‑Click or Copy‑Paste)
|
||||
- macOS (double‑click): open the folder and double‑click `start-relay.command`.
|
||||
- macOS (copy‑paste): `chmod +x ./start-relay.sh && ./start-relay.sh`
|
||||
- Windows (double‑click): open the folder and double‑click `start-relay.bat`.
|
||||
- Windows (copy‑paste): `start-relay.bat`
|
||||
- These start scripts auto‑create a local Python virtual environment, install `websockets`, print your LAN relay URL, and run the relay on port `8787`.
|
||||
|
||||
### First‑Time Setup (Recommended)
|
||||
- macOS/Linux:
|
||||
- `chmod +x ./setup.sh && ./setup.sh`
|
||||
- Windows:
|
||||
- Double‑click `setup.bat`
|
||||
- What it does:
|
||||
- Creates a local virtual environment and installs `websockets`
|
||||
- Starts the static site server on `http://<IP>:8000/`
|
||||
- Starts the WebSocket relay on `ws://<IP>:8787`
|
||||
- Opens the Admin page in your browser at `http://<IP>:8000/`
|
||||
|
||||
### Every Session (Start/Stop)
|
||||
- Start Server:
|
||||
- macOS/Linux: `chmod +x ./start-relay.sh && ./start-relay.sh`
|
||||
- Windows: `start-relay.bat`
|
||||
- Stop Server:
|
||||
- Use the Admin page “Stop Server” button, or press `Ctrl + C` in the relay terminal
|
||||
|
||||
### One‑Time Install (WebSocket Relay)
|
||||
- Windows:
|
||||
- Press `Windows` key → type `PowerShell` → `Enter`
|
||||
- Check Python: `python --version` (or `py --version`)
|
||||
- Install package: `python -m pip install websockets` (or `py -m pip install websockets`)
|
||||
- Start relay: `python server.py` (or `py server.py`)
|
||||
- Quick option: double‑click `start-relay.bat` (or run it in PowerShell/CMD)
|
||||
- Manual option: `python -m pip install websockets` (or `py -m pip install websockets`), then `python server.py` (or `py server.py`)
|
||||
- If prompted by Windows Defender Firewall, click “Allow access”.
|
||||
- macOS:
|
||||
- Press `Cmd+Space` → type `Terminal` → `Enter`
|
||||
- Check Python: `python3 --version`
|
||||
- Install package: `python3 -m pip install websockets`
|
||||
- Start relay: `python3 server.py`
|
||||
- Quick option: double‑click `start-relay.command` (or run `chmod +x ./start-relay.sh && ./start-relay.sh`)
|
||||
- Manual option: `python3 -m pip install websockets`, then `python3 server.py`
|
||||
- Linux (Ubuntu/Debian/Fedora etc.):
|
||||
- Press `Ctrl+Alt+T` to open Terminal
|
||||
- Check Python: `python3 --version`
|
||||
- Install package: `python3 -m pip install websockets`
|
||||
- Start relay: `python3 server.py`
|
||||
- Quick option: `chmod +x ./start-relay.sh && ./start-relay.sh`
|
||||
- Manual option: `python3 -m pip install websockets`, then `python3 server.py`
|
||||
|
||||
### Find Your Therapist Computer’s IP Address
|
||||
- Windows: run `ipconfig` → use the `IPv4 Address` on your active adapter (e.g., `192.168.1.5`).
|
||||
@ -135,6 +217,20 @@ Simple browser-based tool with an Admin panel and a full-screen Patient view. Th
|
||||
- On Windows, allow the relay through the firewall when prompted.
|
||||
- You can disconnect Network Mode and continue Local Mode at any time.
|
||||
|
||||
## Display Options
|
||||
|
||||
- Same Computer (Extended Display)
|
||||
- Connect a second monitor facing the patient
|
||||
- Open `patient.html` in a second browser window
|
||||
- Drag the Patient window to the patient‑facing monitor and use browser fullscreen
|
||||
- Audio unlock: have the patient do one quick gesture (mouse/key) on the Patient screen
|
||||
- Pros: zero network setup, minimal friction
|
||||
|
||||
- Separate Computer (LAN WebSocket)
|
||||
- Use the Network Mode steps above with the Python relay
|
||||
- Share the short Patient link (e.g., `http://<therapist-ip>:8000/patient.html#room-1`)
|
||||
- Audio unlock: one quick gesture on the Patient device; Programmatic audio is recommended
|
||||
|
||||
## Data Storage
|
||||
|
||||
- Presets: `localStorage` key `bls_presets`
|
||||
@ -147,4 +243,4 @@ Simple browser-based tool with an Admin panel and a full-screen Patient view. Th
|
||||
|
||||
## ⚠️ Disclaimer
|
||||
|
||||
This tool is for research, education, and personal experimentation with bilateral stimulation. It is NOT a substitute for professional therapy, diagnosis, or treatment. Use at your own discretion.
|
||||
This tool is for research, education, and personal experimentation with bilateral stimulation, or maybe the fancy lightbar broke down and you need a backup option, or maybe you sprained both your index fingers. It is NOT a substitute for professional therapy, diagnosis, or treatment. Use at your own discretion. You can freely copy or share this with blessings.
|
||||
|
||||
56
index.html
56
index.html
@ -1,10 +1,11 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Bilataral Stimulation Simulator Admin</title>
|
||||
<style>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Bilataral Stimulation Simulator Admin</title>
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='6' fill='%231f6feb'/%3E%3C/svg%3E">
|
||||
<style>
|
||||
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; margin: 0; background: #f7f7f7; color: #111; }
|
||||
header { padding: 16px 20px; background: #222; color: #fff; display: flex; align-items: center; justify-content: space-between; }
|
||||
header h1 { font-size: 18px; margin: 0; }
|
||||
@ -30,6 +31,8 @@
|
||||
#patientControls .danger { background: transparent; color: #d1242f; border: 1px solid #d1242f; padding: 6px 10px; border-radius: 6px; font-weight: 500; }
|
||||
.metrics { display: flex; gap: 12px; font-size: 13px; color: #444; }
|
||||
.small { font-size: 12px; color: #666; display: inline-block; min-width: 60px; }
|
||||
.statusDot { display:inline-block; width:10px; height:10px; border-radius:50%; background:#d1242f; margin-right:6px; vertical-align:middle; }
|
||||
.statusDot.on { background:#2da44e; }
|
||||
.field { display: flex; align-items: center; gap: 8px; flex-wrap: nowrap; }
|
||||
.field label { min-width: 90px; }
|
||||
#speed { width: 360px; }
|
||||
@ -224,23 +227,7 @@
|
||||
<button data-mult="3">3×</button>
|
||||
<button data-mult="4">4×</button>
|
||||
</div>
|
||||
<div class="panel" style="margin-top:8px">
|
||||
<h2>Network Mode</h2>
|
||||
<div class="row">
|
||||
<label>Server URL:</label>
|
||||
<input type="text" id="wsUrl" placeholder="ws://192.168.x.x:8787" style="width:220px">
|
||||
<label>Session ID:</label>
|
||||
<input type="text" id="wsSession" placeholder="room-1" style="width:120px">
|
||||
<button id="wsConnect">Connect</button>
|
||||
<button id="wsDisconnect">Disconnect</button>
|
||||
<span class="small">Status: <span id="wsStatus">Disconnected</span></span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>Patient Join Link:</label>
|
||||
<input type="text" id="joinLink" readonly style="flex:1; min-width:260px">
|
||||
<button id="copyJoinLink">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="row">
|
||||
@ -272,6 +259,31 @@
|
||||
<div id="sessionHistory" class="small" style="max-height:160px; overflow:auto; flex:1"></div>
|
||||
</div>
|
||||
<div class="small">This preview mirrors the patient's view.</div>
|
||||
<div class="panel" style="margin-top:8px">
|
||||
<h2>Network</h2>
|
||||
<div class="row">
|
||||
<label>Relay:</label>
|
||||
<span id="relayStatusDot" class="statusDot"></span>
|
||||
<span id="relayStatusText" class="small">Not running</span>
|
||||
<button id="startRelayBtn">Copy Start Command</button>
|
||||
<button id="stopRelayBtn">Stop Server</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>Step 3:</label>
|
||||
<input type="text" id="wsUrl" placeholder="ws://192.168.x.x:8787" style="width:220px">
|
||||
<input type="text" id="wsSession" placeholder="room-1" style="width:120px">
|
||||
<button id="detectIP">Detect Local IP</button>
|
||||
<button id="wsConnect">Connect</button>
|
||||
<button id="wsDisconnect">Disconnect</button>
|
||||
<span class="small">Status: <span id="wsStatus">Disconnected</span></span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>Step 4:</label>
|
||||
<input type="text" id="joinLink" readonly style="flex:1; min-width:260px">
|
||||
<button id="copyJoinLink">Copy</button>
|
||||
</div>
|
||||
<div id="osHelp" class="small" style="margin-top:6px"></div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
|
||||
94
js/admin.js
94
js/admin.js
@ -104,7 +104,13 @@ function updateTimerLabel(){ var m = Math.max(0, parseInt(timerMinutes.value||'0
|
||||
function refreshPatientUI(){ var n = (patientName.value||'').trim()||'Unnamed'; var p = getPatient(n); sessionCountEl.textContent = String(p.count||0); if(p.last){ var d = new Date(p.last.end); var dur = Math.round((p.last.durationSec||0)); lastSessionInfoEl.textContent = d.toLocaleString()+' • '+dur+'s' } else { lastSessionInfoEl.textContent = '—' } var host = document.getElementById('sessionHistory'); if(host){ if(!p.sessions || !p.sessions.length){ host.innerHTML = '<em>No sessions yet</em>' } else { var rows = p.sessions.slice().sort(function(a,b){ return a.start - b.start }).map(function(s, idx){ var start = new Date(s.start).toLocaleString(); var end = new Date(s.end).toLocaleString(); var dur = Math.round(s.durationSec||0); return '<div>'+String(idx+1)+'. '+start+' → '+end+' • '+dur+'s</div>' }); host.innerHTML = rows.join('') } } }
|
||||
function recordSession(){ var n = (patientName.value||'').trim()||'Unnamed'; var st = BLS.loadState(); if(!st.startedAt) return; var end = Date.now(); var durationSec = Math.floor((end - st.startedAt)/1000); var p = getPatient(n); p.sessions.push({ start: st.startedAt, end: end, durationSec: durationSec }); p.count = (p.count||0)+1; p.last = { start: st.startedAt, end: end, durationSec: durationSec }; setPatient(p); refreshPatientUI() }
|
||||
function refreshPresetList(){ var names = BLS.listPresets(); var html=''; for(var i=0;i<names.length;i++){ var n=names[i]; html += '<option value="'+n+'">'+n+'</option>' } presetList.innerHTML = html }
|
||||
function applyState(next, broadcast){ state = BLS.mergeState(state, next||{}); renderer.setState(state); if(state.running){ renderer.start() } else { renderer.stop() } if(broadcast){ chan.send(state) } }
|
||||
function applyState(next, broadcast){
|
||||
state = BLS.mergeState(state, next||{});
|
||||
renderer.setState(state);
|
||||
if(state.running){ renderer.start() } else { renderer.stop() }
|
||||
if(broadcast){ chan.send(state) }
|
||||
publishState()
|
||||
}
|
||||
function publishState(){ try{ if(wsConnected && ws){ ws.send(JSON.stringify({type:'state', sessionId: wsSid||'default', payload: BLS.loadState()})) } }catch(e){} }
|
||||
shapeControls.addEventListener('click', function(e){ var d = e.target.dataset.shape; if(!d) return; applyState({shape:d}, true); setActive(shapeControls,'shape','shape'); });
|
||||
directionControls.addEventListener('click', function(e){ var d = e.target.dataset.direction; if(!d) return; applyState({direction:d}, true); setActive(directionControls,'direction','direction'); });
|
||||
@ -162,7 +168,63 @@ function stopTimer(){ if(tickId){ clearInterval(tickId); tickId=null } recordSes
|
||||
wsDisconnectBtn.addEventListener('click', wsDisconnect);
|
||||
wsUrl.addEventListener('input', updateJoinLink);
|
||||
wsSession.addEventListener('input', updateJoinLink);
|
||||
copyJoinLink.addEventListener('click', function(){ if(joinLink.value){ navigator.clipboard && navigator.clipboard.writeText(joinLink.value) } });
|
||||
function copyText(t, fallbackElem){
|
||||
var ok=false;
|
||||
if(navigator.clipboard && navigator.clipboard.writeText){
|
||||
try{ navigator.clipboard.writeText(t); ok=true }catch(e){}
|
||||
}
|
||||
if(!ok && fallbackElem){
|
||||
try{ fallbackElem.focus(); fallbackElem.select(); ok=document.execCommand('copy') }catch(e){}
|
||||
}
|
||||
if(!ok){
|
||||
var ta=document.createElement('textarea');
|
||||
ta.value=t; ta.setAttribute('readonly','');
|
||||
ta.style.position='fixed'; ta.style.bottom='0'; ta.style.left='0'; ta.style.opacity='0.01';
|
||||
document.body.appendChild(ta); ta.focus(); ta.select();
|
||||
try{ ok=document.execCommand('copy') }catch(e){}
|
||||
document.body.removeChild(ta)
|
||||
}
|
||||
if(!ok){ try{ alert(t) }catch(_){ } }
|
||||
return ok
|
||||
}
|
||||
copyJoinLink.addEventListener('click', function(){ var v = joinLink.value||''; if(!v) return; var b=this; var ok=copyText(v, joinLink); var old=b.textContent; b.textContent = ok ? 'Copied!' : 'Shown'; setTimeout(function(){ b.textContent=old }, 1000) });
|
||||
var detectIPBtn = document.getElementById('detectIP');
|
||||
if(detectIPBtn){ detectIPBtn.addEventListener('click', function(){
|
||||
function setUrl(ip){ if(!ip) return; wsUrl.value = 'ws://' + ip + ':8787'; if(!(wsSession.value||'').trim()){ wsSession.value = 'room-1' } updateJoinLink(); var v = joinLink.value||''; if(v){ var ok = copyText(v, joinLink); var b = document.getElementById('copyJoinLink'); if(b){ var old=b.textContent; b.textContent = ok? 'Copied!' : 'Copy'; setTimeout(function(){ b.textContent=old }, 1000) } } }
|
||||
var host = location.hostname; if(host && host !== 'localhost' && host !== '127.0.0.1'){ setUrl(host); return }
|
||||
// Attempt server-assisted detection
|
||||
(function(){
|
||||
var probeUrl = 'ws://' + (host || 'localhost') + ':8787'
|
||||
try{
|
||||
var temp = new WebSocket(probeUrl)
|
||||
temp.onopen = function(){ try{ temp.send(JSON.stringify({type:'detect_ip'})) }catch(e){} }
|
||||
temp.onmessage = function(ev){ try{ var msg = JSON.parse(ev.data); if(msg && msg.type==='detect_ip' && msg.ip){ setUrl(msg.ip); wsStatus.textContent = 'IP detected'; try{ temp.close() }catch(e){} return } }catch(e){} try{ temp.close() }catch(e){}
|
||||
// Fallback to WebRTC if server didn't respond with IP
|
||||
tryWebRTC()
|
||||
}
|
||||
temp.onerror = function(){ tryWebRTC() }
|
||||
}catch(_){ tryWebRTC() }
|
||||
})()
|
||||
function tryWebRTC(){
|
||||
var RTC = window.RTCPeerConnection || window.webkitRTCPeerConnection || window.mozRTCPeerConnection
|
||||
if(!RTC){ wsStatus.textContent = 'Detect unavailable'; return }
|
||||
try{
|
||||
var pc = new RTC({iceServers: []}); pc.createDataChannel('x');
|
||||
pc.onicecandidate = function(e){
|
||||
if(!e || !e.candidate){ pc.close(); return }
|
||||
var cand = e.candidate;
|
||||
var ip = (cand && cand.address) || null;
|
||||
if(!ip){
|
||||
var s = cand.candidate || '';
|
||||
var m = s.match(/(?:\s|\b)(\d{1,3}(?:\.\d{1,3}){3})(?:\b)/);
|
||||
if(m) ip = m[1]
|
||||
}
|
||||
if(ip && /^\d+\.\d+\.\d+\.\d+$/.test(ip) && !/^169\./.test(ip)){ setUrl(ip); wsStatus.textContent = 'IP detected'; pc.close() }
|
||||
};
|
||||
pc.createOffer().then(function(o){ return pc.setLocalDescription(o) }).catch(function(){ wsStatus.textContent = 'Detect failed' })
|
||||
}catch(_){ wsStatus.textContent = 'Detect failed' }
|
||||
}
|
||||
}) }
|
||||
openPatient.addEventListener('click', function(){ window.open('patient.html','bls-patient'); });
|
||||
renderer.resize(360,220);
|
||||
updateSpeedLabel(); updateSizeLabel(); updateYPercentLabel(); updateTimerLabel(); updateGlowIntensityLabel(); updateGlowRateLabel(); updateRampLabel(); updateEdgePauseLabel(); updateWiggleLabel(); updatePanRateLabel(); updateCueRateLabel(); updateVolumeLabel(); statusText.textContent = state.running? 'Running':'Idle'; setActive(shapeControls,'shape','shape'); setActive(directionControls,'direction','direction'); setActive(easingControls,'easing','easingMode');
|
||||
@ -197,7 +259,31 @@ if(document.readyState==='loading'){ document.addEventListener('DOMContentLoaded
|
||||
var wsStatus = document.getElementById('wsStatus');
|
||||
var joinLink = document.getElementById('joinLink');
|
||||
var copyJoinLink = document.getElementById('copyJoinLink');
|
||||
var startRelayBtn = document.getElementById('startRelayBtn');
|
||||
var stopRelayBtn = document.getElementById('stopRelayBtn');
|
||||
var relayStatusDot = document.getElementById('relayStatusDot');
|
||||
var relayStatusText = document.getElementById('relayStatusText');
|
||||
var ws = null; var wsConnected = false; var wsSid = null;
|
||||
function updateJoinLink(){ var url = (wsUrl.value||'').trim(); var sid = (wsSession.value||'').trim(); var base = location.origin + '/patient.html'; if(url && sid){ joinLink.value = base + '?url=' + encodeURIComponent(url) + '&sid=' + encodeURIComponent(sid) } else { joinLink.value = '' } }
|
||||
function wsConnect(){ var url = (wsUrl.value||'').trim(); var sid = (wsSession.value||'').trim()||'default'; if(ws){ try{ ws.close() }catch(e){} ws=null } if(!url){ wsStatus.textContent = 'Missing URL'; return } ws = new WebSocket(url); wsSid = sid; wsStatus.textContent = 'Connecting...'; ws.onopen = function(){ wsConnected = true; wsStatus.textContent = 'Connected'; try{ ws.send(JSON.stringify({type:'join', sessionId:sid, role:'admin'})) }catch(e){} }; ws.onclose = function(){ wsConnected = false; wsStatus.textContent = 'Disconnected' }; ws.onerror = function(){ wsStatus.textContent = 'Error' }; ws.onmessage = function(ev){ try{ var msg = JSON.parse(ev.data); if(msg && msg.type==='joined'){ wsStatus.textContent = 'Connected' } }catch(e){} } }
|
||||
function publishState(){ try{ if(wsConnected && ws){ ws.send(JSON.stringify({type:'state', sessionId: wsSid||'default', payload: BLS.loadState()})) } }catch(e){} }
|
||||
function updateJoinLink(){ var sid = (wsSession.value||'').trim(); var base = location.origin + '/patient.html'; if(sid){ joinLink.value = base + '#' + encodeURIComponent(sid) } else { joinLink.value = '' } }
|
||||
function renderOsHelp(){ var ua = navigator.userAgent||''; var isWin = /Windows/i.test(ua); var isMac = /Macintosh|Mac OS/i.test(ua); var isLinux = /Linux/i.test(ua) && !isWin && !isMac; var setup = (isWin? 'setup.bat' : 'chmod +x ./setup.sh && ./setup.sh'); var start = (isWin? 'start-relay.bat' : 'chmod +x ./start-relay.sh && ./start-relay.sh'); var open = (isWin? 'Open PowerShell' : isMac? 'Cmd+Space → Terminal' : 'Open Terminal'); return { setup: setup, start: start, open: open } }
|
||||
(function(){ var el = document.getElementById('osHelp'); if(!el) return; var h = renderOsHelp(); var text = 'First time: ' + h.open + ', cd to the folder, run: ' + h.setup + ' • Every session: ' + h.start; el.innerText = text })();
|
||||
function copyText(t){ var ok=false; if(navigator.clipboard && navigator.clipboard.writeText){ try{ navigator.clipboard.writeText(t); ok=true }catch(e){} } if(!ok){ var ta=document.createElement('textarea'); ta.value=t; ta.setAttribute('readonly',''); ta.style.position='fixed'; ta.style.bottom='0'; ta.style.left='0'; ta.style.opacity='0.01'; document.body.appendChild(ta); ta.focus(); ta.select(); try{ ok=document.execCommand('copy') }catch(e){} document.body.removeChild(ta) } if(!ok){ try{ alert(t) }catch(_){ } } return ok }
|
||||
function setRelayStatus(on){ if(relayStatusDot){ relayStatusDot.classList.toggle('on', !!on) } if(relayStatusText){ relayStatusText.textContent = on? 'Running' : 'Not running' } }
|
||||
(function(){ var btn = document.getElementById('wsConnect'); if(btn){ btn.disabled = true } })();
|
||||
(function(){
|
||||
var pollMs = 3000;
|
||||
function schedule(){ setTimeout(run, pollMs) }
|
||||
function run(){ checkRelayOnce().then(function(ok){ setRelayStatus(!!ok); var btn = document.getElementById('wsConnect'); if(btn){ btn.disabled = !ok } pollMs = ok ? 3000 : Math.min(Math.round(pollMs*1.5), 30000); schedule() }).catch(function(){ setRelayStatus(false); var btn = document.getElementById('wsConnect'); if(btn){ btn.disabled = true } pollMs = Math.min(Math.round(pollMs*1.5), 30000); schedule() }) }
|
||||
async function checkRelayOnce(){ var host = location.hostname || 'localhost'; var url = 'ws://' + host + ':8787'; try{ return await new Promise(function(resolve){ var temp = new WebSocket(url); var done=false; var timer = setTimeout(function(){ if(!done){ resolve(false); if(temp && temp.readyState===1){ try{ temp.close() }catch(e){} } } }, 1500);
|
||||
temp.onopen = function(){ try{ temp.send(JSON.stringify({type:'ping'})) }catch(e){} };
|
||||
temp.onmessage = function(ev){ try{ var msg = JSON.parse(ev.data); if(msg && msg.type==='pong'){ done=true; clearTimeout(timer); if(temp && temp.readyState===1){ try{ temp.close() }catch(e){} } resolve(true); return } }catch(e){} resolve(false) };
|
||||
temp.onerror = function(){ resolve(false) };
|
||||
temp.onclose = function(){ if(!done){ resolve(false) } };
|
||||
}) }catch(_){ return false } }
|
||||
run()
|
||||
})();
|
||||
if(startRelayBtn){ startRelayBtn.addEventListener('click', function(){ var h = renderOsHelp(); var ok = copyText(h.start); var old = startRelayBtn.textContent; startRelayBtn.textContent = ok? 'Copied!' : 'Copy Start Command'; setTimeout(function(){ startRelayBtn.textContent = old }, 1200) }) }
|
||||
if(stopRelayBtn){ stopRelayBtn.addEventListener('click', function(){ var host = location.hostname || 'localhost'; var url = 'ws://' + host + ':8787'; try{ var temp = new WebSocket(url); temp.onopen = function(){ try{ temp.send(JSON.stringify({type:'shutdown'})) }catch(e){} }; setTimeout(function(){ setRelayStatus(false) }, 1500) }catch(e){} }) }
|
||||
function wsConnect(){ var url = (wsUrl.value||'').trim(); var sid = (wsSession.value||'').trim()||'default'; var btn = document.getElementById('wsConnect'); if(btn && btn.disabled){ wsStatus.textContent = 'Relay not running'; return } if(ws){ try{ ws.close() }catch(e){} ws=null } if(!url){ wsStatus.textContent = 'Missing URL'; return } try{ ws = new WebSocket(url) }catch(e){ wsStatus.textContent = 'Error'; return } wsSid = sid; wsStatus.textContent = 'Connecting...'; ws.onopen = function(){ wsConnected = true; wsStatus.textContent = 'Connected'; try{ ws.send(JSON.stringify({type:'join', sessionId:sid, role:'admin'})) }catch(e){} publishState() }; ws.onclose = function(){ wsConnected = false; wsStatus.textContent = 'Disconnected' }; ws.onerror = function(){ wsStatus.textContent = 'Error' }; ws.onmessage = function(ev){ try{ var msg = JSON.parse(ev.data); if(msg && msg.type==='joined'){ wsStatus.textContent = 'Connected' } }catch(e){} } }
|
||||
function wsDisconnect(){ if(ws){ try{ ws.close() }catch(e){} } ws=null; wsConnected=false; wsStatus.textContent = 'Disconnected' }
|
||||
|
||||
31
patient.html
31
patient.html
@ -25,11 +25,34 @@ BLS<!doctype html>
|
||||
var renderer = BLS.Renderer(canvas)
|
||||
var chan = BLS.createChannel('bls-session')
|
||||
var qs = new URLSearchParams(location.search)
|
||||
var wsUrl = qs.get('url')
|
||||
var wsSid = qs.get('sid') || 'default'
|
||||
var hashSid = (location.hash||'').replace('#','')
|
||||
var wsSid = qs.get('sid') || hashSid || 'default'
|
||||
var wsUrl = qs.get('url') || ('ws://' + (location.hostname||'localhost') + ':8787')
|
||||
var ws = null
|
||||
chan.on(function(msg){ if(!msg || typeof msg !== 'object') return; var apply = Object.assign({}, msg); if(apply && apply.rampEnabled === false){ apply.easingMode = 'linear' } if(apply && apply.glowEnabled === false){ apply.glowIntensity = 0 } renderer.setState(apply); var st = renderer.getState(); if(st.running){ renderer.start() } else { renderer.stop() } })
|
||||
function wsConnect(){ if(!wsUrl) return; try{ ws = new WebSocket(wsUrl) }catch(e){ return } ws.onopen = function(){ try{ ws.send(JSON.stringify({type:'join', sessionId: wsSid, role:'patient'})) }catch(e){} }; ws.onmessage = function(ev){ try{ var msg = JSON.parse(ev.data); if(msg && msg.type==='state'){ var apply = Object.assign({}, msg.payload||{}); if(apply && apply.rampEnabled === false){ apply.easingMode = 'linear' } if(apply && apply.glowEnabled === false){ apply.glowIntensity = 0 } renderer.setState(apply); var st = renderer.getState(); if(st.running){ renderer.start() } else { renderer.stop() } } }catch(e){} }; ws.onclose = function(){ ws = null }
|
||||
function wsConnect(){ if(!wsUrl) return; try{ ws = new WebSocket(wsUrl) }catch(e){ return } ws.onopen = function(){ try{ ws.send(JSON.stringify({type:'join', sessionId: wsSid, role:'patient'})) }catch(e){} };
|
||||
ws.onmessage = function(ev){
|
||||
try{
|
||||
var msg = JSON.parse(ev.data);
|
||||
if(msg && msg.type==='state'){
|
||||
var apply = Object.assign({}, msg.payload||{});
|
||||
if(apply && apply.rampEnabled === false){ apply.easingMode = 'linear' }
|
||||
if(apply && apply.glowEnabled === false){ apply.glowIntensity = 0 }
|
||||
renderer.setState(apply);
|
||||
var st = renderer.getState();
|
||||
if(!st.running){ stopAudio(); stopProgrammatic(); renderer.stop(); return }
|
||||
if(st.audioEnabled){
|
||||
if(st.audioMode==='file'){
|
||||
(async function(){ if(!audioFolder){ await ensureFolder() } if(st.audioName){ await loadAudioByName(st.audioName); playBuffer() } })()
|
||||
} else {
|
||||
stopAudio(); if(!audioUnlocked) bindUnlock(); startProgrammatic(st)
|
||||
}
|
||||
} else { stopAudio(); stopProgrammatic() }
|
||||
renderer.start()
|
||||
}
|
||||
}catch(e){}
|
||||
};
|
||||
ws.onclose = function(){ ws = null }
|
||||
}
|
||||
if(wsUrl){ wsConnect() }
|
||||
renderer.setState(BLS.loadState())
|
||||
@ -37,7 +60,7 @@ BLS<!doctype html>
|
||||
var audioCtx = null, panner = null, gain = null, source = null, audioFolder = null, audioBuffer = null
|
||||
var audioUnlocked = false
|
||||
var pendingProgramState = null
|
||||
function bindUnlock(){ ['pointerdown','keydown','touchstart','mousemove'].forEach(function(ev){ window.addEventListener(ev, function once(){ try{ AudioEngine.ensureCtx(); audioUnlocked = true }catch(e){} if(pendingProgramState){ var st = pendingProgramState; pendingProgramState = null; startProgrammatic(st) } try{ window.removeEventListener(ev, once) }catch(_){ } }, { once:true }) }) }
|
||||
function bindUnlock(){ ['pointerdown','keydown','touchstart','mousemove'].forEach(function(ev){ window.addEventListener(ev, function once(){ try{ AudioEngine.ensureCtx(); audioUnlocked = true; AudioEngine.playPing(0) }catch(e){} if(pendingProgramState){ var st = pendingProgramState; pendingProgramState = null; startProgrammatic(st) } try{ window.removeEventListener(ev, once) }catch(_){ } }, { once:true }) }) }
|
||||
bindUnlock()
|
||||
var progRunning = false
|
||||
function stopProgrammatic(){ AudioEngine.stopNoise(); progRunning=false }
|
||||
|
||||
43
server.py
43
server.py
@ -1,8 +1,10 @@
|
||||
import asyncio
|
||||
import json
|
||||
import websockets
|
||||
import socket
|
||||
|
||||
SESSIONS = {}
|
||||
STOP = None
|
||||
|
||||
async def handler(ws):
|
||||
session_id = None
|
||||
@ -26,6 +28,43 @@ async def handler(ws):
|
||||
await peer.send(json.dumps({'type':'state','payload':payload}))
|
||||
except Exception:
|
||||
pass
|
||||
elif t in ('whoami','detect_ip'):
|
||||
# Determine the host's primary LAN IP without external calls
|
||||
ip = '127.0.0.1'
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.connect(('8.8.8.8', 80))
|
||||
ip = s.getsockname()[0]
|
||||
s.close()
|
||||
except Exception:
|
||||
ip = socket.gethostbyname(socket.gethostname())
|
||||
try:
|
||||
await ws.send(json.dumps({'type':'detect_ip','ip': ip}))
|
||||
except Exception:
|
||||
pass
|
||||
elif t == 'shutdown':
|
||||
try:
|
||||
ra = ws.remote_address[0] if ws.remote_address else None
|
||||
except Exception:
|
||||
ra = None
|
||||
local_ip = '127.0.0.1'
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.connect(('8.8.8.8', 80))
|
||||
local_ip = s.getsockname()[0]
|
||||
s.close()
|
||||
except Exception:
|
||||
try:
|
||||
local_ip = socket.gethostbyname(socket.gethostname())
|
||||
except Exception:
|
||||
local_ip = '127.0.0.1'
|
||||
if ra in ('127.0.0.1', '::1', local_ip):
|
||||
try:
|
||||
await ws.send(json.dumps({'type':'shutdown_ack'}))
|
||||
except Exception:
|
||||
pass
|
||||
if STOP and not STOP.done():
|
||||
STOP.set_result(True)
|
||||
elif t == 'ping':
|
||||
await ws.send(json.dumps({'type':'pong'}))
|
||||
finally:
|
||||
@ -33,9 +72,11 @@ async def handler(ws):
|
||||
SESSIONS[session_id].discard(ws)
|
||||
|
||||
async def main():
|
||||
global STOP
|
||||
STOP = asyncio.Future()
|
||||
print('Starting BLS relay on ws://0.0.0.0:8787')
|
||||
async with websockets.serve(handler, '0.0.0.0', 8787, ping_interval=20, ping_timeout=20):
|
||||
await asyncio.Future()
|
||||
await STOP
|
||||
|
||||
if __name__ == '__main__':
|
||||
asyncio.run(main())
|
||||
|
||||
25
setup.bat
Normal file
25
setup.bat
Normal file
@ -0,0 +1,25 @@
|
||||
@echo off
|
||||
setlocal enabledelayedexpansion
|
||||
cd /d "%~dp0"
|
||||
set USEPY=
|
||||
where py >nul 2>&1 && set USEPY=py
|
||||
if not defined USEPY where python >nul 2>&1 && set USEPY=python
|
||||
if not defined USEPY echo Python not found & exit /b 1
|
||||
if exist ".venv" (
|
||||
echo Using existing venv
|
||||
) else (
|
||||
if "%USEPY%"=="py" (
|
||||
%USEPY% -3 -m venv .venv
|
||||
) else (
|
||||
%USEPY% -m venv .venv
|
||||
)
|
||||
)
|
||||
set VPY=.\.venv\Scripts\python
|
||||
"%VPY%" -m pip install -U pip websockets
|
||||
for /f "delims=" %%I in ('"%VPY%" -c "import socket;s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM);s.connect(('8.8.8.8',80));print(s.getsockname()[0]);s.close()"') do set IP=%%I
|
||||
if not defined IP set IP=localhost
|
||||
rem Start static server if not running
|
||||
netstat -ano | findstr ":8000" >nul || start /min cmd /c "%VPY% -m http.server 8000"
|
||||
start "" http://%IP%:8000/
|
||||
echo Relay URL: ws://%IP%:8787
|
||||
"%VPY%" server.py
|
||||
28
setup.sh
Normal file
28
setup.sh
Normal file
@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
if command -v python3 >/dev/null 2>&1; then PY=python3; elif command -v python >/dev/null 2>&1; then PY=python; else echo "Python not found"; exit 1; fi
|
||||
if [ ! -d ".venv" ]; then "$PY" -m venv .venv; fi
|
||||
VENV_PY="./.venv/bin/python"
|
||||
"$VENV_PY" -m pip install -U pip websockets
|
||||
IP="$($VENV_PY - <<'PYCODE'
|
||||
import socket
|
||||
s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM)
|
||||
try:
|
||||
s.connect(('8.8.8.8',80))
|
||||
print(s.getsockname()[0])
|
||||
except Exception:
|
||||
print('localhost')
|
||||
finally:
|
||||
s.close()
|
||||
PYCODE
|
||||
)"
|
||||
# Start static server if not running
|
||||
if ! lsof -ti:8000 >/dev/null 2>&1; then nohup "$VENV_PY" -m http.server 8000 >/dev/null 2>&1 & fi
|
||||
URL="http://$IP:8000/"
|
||||
case "$(uname -s)" in
|
||||
Darwin) open "$URL" ;;
|
||||
Linux) command -v xdg-open >/dev/null 2>&1 && xdg-open "$URL" || true ;;
|
||||
esac
|
||||
echo "Relay URL: ws://$IP:8787"
|
||||
exec "$VENV_PY" server.py
|
||||
23
start-relay.bat
Normal file
23
start-relay.bat
Normal file
@ -0,0 +1,23 @@
|
||||
@echo off
|
||||
setlocal enabledelayedexpansion
|
||||
cd /d "%~dp0"
|
||||
set USEPY=
|
||||
where py >nul 2>&1 && set USEPY=py
|
||||
if not defined USEPY where python >nul 2>&1 && set USEPY=python
|
||||
if not defined USEPY echo Python not found & exit /b 1
|
||||
if exist ".venv" (
|
||||
echo Using existing venv
|
||||
) else (
|
||||
if "%USEPY%"=="py" (
|
||||
%USEPY% -3 -m venv .venv
|
||||
) else (
|
||||
%USEPY% -m venv .venv
|
||||
)
|
||||
)
|
||||
set VPY=.\.venv\Scripts\python
|
||||
"%VPY%" -m pip install -U pip websockets
|
||||
for /f "delims=" %%I in ('"%VPY%" -c "import socket;s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM);s.connect(('8.8.8.8',80));print(s.getsockname()[0]);s.close()"') do set IP=%%I
|
||||
if not defined IP set IP=localhost
|
||||
netstat -ano | findstr ":8000" >nul || start /min cmd /c "%VPY% -m http.server 8000"
|
||||
echo Relay URL: ws://%IP%:8787
|
||||
"%VPY%" server.py
|
||||
4
start-relay.command
Normal file
4
start-relay.command
Normal file
@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
cd "$(dirname "$0")"
|
||||
chmod +x ./start-relay.sh
|
||||
./start-relay.sh
|
||||
22
start-relay.sh
Executable file
22
start-relay.sh
Executable file
@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
if command -v python3 >/dev/null 2>&1; then PY=python3; elif command -v python >/dev/null 2>&1; then PY=python; else echo "Python not found"; exit 1; fi
|
||||
if [ ! -d ".venv" ]; then "$PY" -m venv .venv; fi
|
||||
VENV_PY="./.venv/bin/python"
|
||||
"$VENV_PY" -m pip install -U pip websockets
|
||||
if ! lsof -ti:8000 >/dev/null 2>&1; then nohup "$VENV_PY" -m http.server 8000 >/dev/null 2>&1 & fi
|
||||
IP="$($VENV_PY - <<'PYCODE'
|
||||
import socket
|
||||
s=socket.socket(socket.AF_INET,socket.SOCK_DGRAM)
|
||||
try:
|
||||
s.connect(('8.8.8.8',80))
|
||||
print(s.getsockname()[0])
|
||||
except Exception:
|
||||
print('localhost')
|
||||
finally:
|
||||
s.close()
|
||||
PYCODE
|
||||
)"
|
||||
echo "Relay URL: ws://$IP:8787"
|
||||
exec "$VENV_PY" server.py
|
||||
Loading…
Reference in New Issue
Block a user